diff --git a/.changeset/twelve-spiders-fail.md b/.changeset/twelve-spiders-fail.md
new file mode 100644
index 00000000..930d420f
--- /dev/null
+++ b/.changeset/twelve-spiders-fail.md
@@ -0,0 +1,5 @@
+---
+'@hono/react-renderer': patch
+---
+
+feat: introduce React Renderer
diff --git a/packages/react-renderer/README.md b/packages/react-renderer/README.md
new file mode 100644
index 00000000..d63b9a8d
--- /dev/null
+++ b/packages/react-renderer/README.md
@@ -0,0 +1,224 @@
+# React Renderer Middleware
+
+React Renderer Middleware allows for the easy creation of a renderer based on React for Hono.
+
+## Installation
+
+```txt
+npm i @hono/react-renderer react react-dom hono
+npm i -D @types/react @types/react-dom
+```
+
+## Settings
+
+`tsconfig.json`:
+
+```json
+{
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "react"
+ }
+}
+```
+
+## Usage
+
+### Basic
+
+```tsx
+import { Hono } from 'hono'
+import { reactRenderer } from '@hono/react-renderer'
+
+const app = new Hono()
+
+app.get(
+ '*',
+ reactRenderer(({ children }) => {
+ return (
+
+
+ React + Hono
+ {children}
+
+
+ )
+ })
+)
+
+app.get('/', (c) => {
+ return c.render(Welcome!
)
+})
+```
+
+### Extending `Props`
+
+You can define types of `Props`:
+
+```tsx
+declare module '@hono/react-renderer' {
+ interface Props {
+ title: string
+ }
+}
+```
+
+Then, you can use it in the `reactRenderer()` function and pass values as a second argument to `c.render()`:
+
+```tsx
+app.get(
+ '*',
+ reactRenderer(({ children, title }) => {
+ return (
+
+
+ {title}
+
+
+ {children}
+
+
+ )
+ })
+)
+
+app.get('/', (c) => {
+ return c.render(Welcome!
, {
+ title: 'Top Page',
+ })
+})
+```
+
+### `useRequestContext()`
+
+You can get an instance of `Context` in a function component:
+
+```tsx
+const Component = () => {
+ const c = useRequestContext()
+ return You access {c.req.url}
+}
+
+app.get('/', (c) => {
+ return c.render()
+})
+```
+
+## Options
+
+### `docType`
+
+If you set it `true`, `DOCTYPE` will be added:
+
+```tsx
+app.get(
+ '*',
+ reactRenderer(
+ ({ children }) => {
+ return (
+
+
+ {children}
+
+
+ )
+ },
+ {
+ docType: true,
+ }
+ )
+)
+```
+
+The HTML is the following:
+
+```html
+
+
+
+
+
+
+```
+
+You can specify the `docType` as you like.
+
+```tsx
+app.get(
+ '*',
+ reactRenderer(
+ ({ children }) => {
+ return (
+
+
+ {children}
+
+
+ )
+ },
+ {
+ docType:
+ '',
+ }
+ )
+)
+```
+
+### `stream`
+
+It enables returning a streaming response. You can use a `Suspense` with it:
+
+```tsx
+import { reactRenderer } from '@hono/react-renderer'
+import { Suspense } from 'react'
+
+app.get(
+ '*',
+ reactRenderer(
+ ({ children }) => {
+ return (
+
+
+ {children}
+
+
+ )
+ },
+ {
+ stream: true,
+ }
+ )
+)
+
+let done = false
+
+const Component = () => {
+ if (done) {
+ return Done!
+ }
+ throw new Promise((resolve) => {
+ done = true
+ setTimeout(resolve, 1000)
+ })
+}
+
+app.get('/', (c) => {
+ return c.render(
+
+
+
+ )
+})
+```
+
+## Limitation
+
+A streaming feature is not available on Vite or Vitest.
+
+## Author
+
+Yusuke Wada
+
+## License
+
+MIT
diff --git a/packages/react-renderer/package.json b/packages/react-renderer/package.json
new file mode 100644
index 00000000..37817311
--- /dev/null
+++ b/packages/react-renderer/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@hono/react-renderer",
+ "version": "0.0.0",
+ "description": "React Renderer Middleware for Hono",
+ "type": "module",
+ "main": "dist/index.js",
+ "module": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "test": "vitest --run",
+ "build": "tsup ./src/index.ts --external hono,react,react-dom --format esm,cjs --dts",
+ "publint": "publint"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "license": "MIT",
+ "publishConfig": {
+ "registry": "https://registry.npmjs.org",
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/honojs/middleware.git"
+ },
+ "homepage": "https://github.com/honojs/middleware",
+ "peerDependencies": {
+ "hono": "*"
+ },
+ "devDependencies": {
+ "@types/react": "^18",
+ "@types/react-dom": "^18.2.17",
+ "hono": "^3.11.7",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "tsup": "^8.0.1",
+ "vitest": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/packages/react-renderer/src/index.ts b/packages/react-renderer/src/index.ts
new file mode 100644
index 00000000..7e629726
--- /dev/null
+++ b/packages/react-renderer/src/index.ts
@@ -0,0 +1,9 @@
+export * from './react-renderer'
+
+export type Props = {}
+
+declare module 'hono' {
+ interface ContextRenderer {
+ (children: React.ReactElement, props?: Props): Response | Promise
+ }
+}
diff --git a/packages/react-renderer/src/react-renderer.ts b/packages/react-renderer/src/react-renderer.ts
new file mode 100644
index 00000000..c18dd56e
--- /dev/null
+++ b/packages/react-renderer/src/react-renderer.ts
@@ -0,0 +1,68 @@
+import type { Context } from 'hono'
+import type { Env, MiddlewareHandler } from 'hono/types'
+import React from 'react'
+import { renderToString, renderToReadableStream } from 'react-dom/server'
+import type { Props } from '.'
+
+type RendererOptions = {
+ docType?: boolean | string
+ stream?: boolean | Record
+}
+
+type BaseProps = {
+ c: Context
+ children: React.ReactElement
+}
+
+const RequestContext = React.createContext(null)
+
+const createRenderer =
+ (c: Context, component?: React.FC, options?: RendererOptions) =>
+ async (children: React.ReactElement, props?: Props) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const node = component ? component({ children, c, ...props }) : children
+
+ if (options?.stream) {
+ const stream = await renderToReadableStream(
+ React.createElement(RequestContext.Provider, { value: c }, node)
+ )
+ return c.body(stream, {
+ headers:
+ options.stream === true
+ ? {
+ 'Transfer-Encoding': 'chunked',
+ 'Content-Type': 'text/html; charset=UTF-8',
+ }
+ : options.stream,
+ })
+ } else {
+ const docType =
+ typeof options?.docType === 'string'
+ ? options.docType
+ : options?.docType === true
+ ? ''
+ : ''
+ const body =
+ docType + renderToString(React.createElement(RequestContext.Provider, { value: c }, node))
+ return c.html(body)
+ }
+ }
+
+export const reactRenderer = (
+ component?: React.FC,
+ options?: RendererOptions
+): MiddlewareHandler =>
+ function reactRenderer(c, next) {
+ c.setRenderer(createRenderer(c, component, options))
+ return next()
+ }
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const useRequestContext = (): Context => {
+ const c = React.useContext(RequestContext)
+ if (!c) {
+ throw new Error('RequestContext is not provided.')
+ }
+ return c
+}
diff --git a/packages/react-renderer/test/react-renderer.test.tsx b/packages/react-renderer/test/react-renderer.test.tsx
new file mode 100644
index 00000000..19872760
--- /dev/null
+++ b/packages/react-renderer/test/react-renderer.test.tsx
@@ -0,0 +1,111 @@
+import { Hono } from 'hono'
+import { reactRenderer, useRequestContext } from '../src/react-renderer'
+
+const RequestUrl = () => {
+ const c = useRequestContext()
+ return <>{c.req.url}>
+}
+
+describe('Basic', () => {
+ const app = new Hono()
+ app.use(
+ // @ts-expect-error - `title` is not defined
+ reactRenderer(({ children, title }) => {
+ return (
+
+ {title}
+ {children}
+
+ )
+ })
+ )
+ app.get('/', (c) => {
+ return c.render(
+
+
+
,
+ {
+ title: 'Title',
+ }
+ )
+ })
+
+ it('Should return HTML with layout', async () => {
+ const res = await app.request('http://localhost/')
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe(
+ 'Titlehttp://localhost/
'
+ )
+ })
+
+ it('Should return HTML without layout', async () => {
+ const app = new Hono()
+ app.use('*', reactRenderer())
+ app.get('/', (c) =>
+ c.render(
+
+
+
,
+ { title: 'Title' }
+ )
+ )
+ const res = await app.request('http://localhost/')
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('http://localhost/
')
+ })
+
+ it('Should return a default doctype', async () => {
+ const app = new Hono()
+ app.use(
+ '*',
+ reactRenderer(
+ ({ children }) => {
+ return (
+
+ {children}
+
+ )
+ },
+ { docType: true }
+ )
+ )
+ app.get('/', (c) => c.render(Hello
, { title: 'Title' }))
+ const res = await app.request('/')
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('Hello
')
+ })
+
+ it('Should return a custom doctype', async () => {
+ const app = new Hono()
+ app.use(
+ '*',
+ reactRenderer(
+ ({ children }) => {
+ return (
+
+ {children}
+
+ )
+ },
+ {
+ docType:
+ '',
+ }
+ )
+ )
+ app.get('/', (c) => c.render(Hello
, { title: 'Title' }))
+ const res = await app.request('/')
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe(
+ 'Hello
'
+ )
+ })
+})
+
+describe('Streaming', () => {
+ it.skip('Vitest does not support Streaming')
+})
diff --git a/packages/react-renderer/tsconfig.json b/packages/react-renderer/tsconfig.json
new file mode 100644
index 00000000..624fd152
--- /dev/null
+++ b/packages/react-renderer/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "node",
+ "rootDir": "./",
+ "outDir": "./dist",
+ "jsx": "react-jsx",
+ "jsxImportSource": "react"
+ },
+ "include": [
+ "src/**/*.ts",
+ "test/**/*.tsx",
+ ],
+}
\ No newline at end of file
diff --git a/packages/react-renderer/vitest.config.ts b/packages/react-renderer/vitest.config.ts
new file mode 100644
index 00000000..17b54e48
--- /dev/null
+++ b/packages/react-renderer/vitest.config.ts
@@ -0,0 +1,8 @@
+///
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ globals: true,
+ },
+})
diff --git a/yarn.lock b/yarn.lock
index fa1bb857..9232131f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1833,6 +1833,22 @@ __metadata:
languageName: unknown
linkType: soft
+"@hono/react-renderer@workspace:packages/react-renderer":
+ version: 0.0.0-use.local
+ resolution: "@hono/react-renderer@workspace:packages/react-renderer"
+ dependencies:
+ "@types/react": "npm:^18"
+ "@types/react-dom": "npm:^18.2.17"
+ hono: "npm:^3.11.7"
+ react: "npm:^18.2.0"
+ react-dom: "npm:^18.2.0"
+ tsup: "npm:^8.0.1"
+ vitest: "npm:^1.0.4"
+ peerDependencies:
+ hono: "*"
+ languageName: unknown
+ linkType: soft
+
"@hono/sentry@workspace:packages/sentry":
version: 0.0.0-use.local
resolution: "@hono/sentry@workspace:packages/sentry"
@@ -4287,7 +4303,16 @@ __metadata:
languageName: node
linkType: hard
-"@types/react@npm:^18":
+"@types/react-dom@npm:^18.2.17":
+ version: 18.2.17
+ resolution: "@types/react-dom@npm:18.2.17"
+ dependencies:
+ "@types/react": "npm:*"
+ checksum: 33b53078ed7e9e0cfc4dc691e938f7db1cc06353bc345947b41b581c3efe2b980c9e4eb6460dbf5ddc521dd91959194c970221a2bd4bfad9d23ebce338e12938
+ languageName: node
+ linkType: hard
+
+"@types/react@npm:*, @types/react@npm:^18":
version: 18.2.45
resolution: "@types/react@npm:18.2.45"
dependencies:
@@ -10312,6 +10337,13 @@ __metadata:
languageName: node
linkType: hard
+"hono@npm:^3.11.7":
+ version: 3.11.7
+ resolution: "hono@npm:3.11.7"
+ checksum: 6665e26801cb4c4334e09dbb42453bf40e1daae9a44bf9a1ed22815f6cb701c0d9ac1a4452624234f36093996c8afff0a41d2d5b52a77f4f460178be48e022bf
+ languageName: node
+ linkType: hard
+
"hosted-git-info@npm:^2.1.4":
version: 2.8.9
resolution: "hosted-git-info@npm:2.8.9"
@@ -16706,6 +16738,18 @@ __metadata:
languageName: node
linkType: hard
+"react-dom@npm:^18.2.0":
+ version: 18.2.0
+ resolution: "react-dom@npm:18.2.0"
+ dependencies:
+ loose-envify: "npm:^1.1.0"
+ scheduler: "npm:^0.23.0"
+ peerDependencies:
+ react: ^18.2.0
+ checksum: 66dfc5f93e13d0674e78ef41f92ed21dfb80f9c4ac4ac25a4b51046d41d4d2186abc915b897f69d3d0ebbffe6184e7c5876f2af26bfa956f179225d921be713a
+ languageName: node
+ linkType: hard
+
"react-is@npm:^18.0.0":
version: 18.2.0
resolution: "react-is@npm:18.2.0"
@@ -17441,6 +17485,15 @@ __metadata:
languageName: node
linkType: hard
+"scheduler@npm:^0.23.0":
+ version: 0.23.0
+ resolution: "scheduler@npm:0.23.0"
+ dependencies:
+ loose-envify: "npm:^1.1.0"
+ checksum: b777f7ca0115e6d93e126ac490dbd82642d14983b3079f58f35519d992fa46260be7d6e6cede433a92db70306310c6f5f06e144f0e40c484199e09c1f7be53dd
+ languageName: node
+ linkType: hard
+
"scoped-regex@npm:^2.0.0":
version: 2.1.0
resolution: "scoped-regex@npm:2.1.0"