hono-jsx

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Hono JSX Skill

Hono JSX Skill

Build UI with
hono/jsx
: server-rendered HTML first, a small amount of client-side JSX only where the browser needs state. The
hono
skill covers the JSX syntax and
c.html()
; this skill covers how to structure the app. Cloudflare Workers with Vite is the default setup; see "Other runtimes" for the rest.
使用
hono/jsx
构建 UI:优先采用服务端渲染 HTML,仅在浏览器需要状态管理的地方使用少量客户端 JSX。
hono
skill 涵盖 JSX 语法和
c.html()
;本 skill 讲解如何组织应用。默认环境是结合 Vite 的 Cloudflare Workers;其余环境请参阅“其他运行时”。

No React

不使用 React

hono/jsx
is the JSX runtime. Do not bring React in:
  • Do not install
    react
    ,
    react-dom
    ,
    @types/react
    , or
    @vitejs/plugin-react
    . Do not import from
    react
    .
  • tsconfig.json
    has
    "jsx": "react-jsx"
    and
    "jsxImportSource": "hono/jsx"
    . The
    react-jsx
    value is the transform name, not a React dependency.
  • Server components are plain functions rendered once per request. No hooks, no state, no event handlers on the server. Need data: make the component
    async
    and
    await
    . Need interactivity: a client component with
    hono/jsx/dom
    , for that part only.
  • Write
    class
    , not
    className
    .
  • vite-ssr-components
    exports both flavors: import from
    vite-ssr-components/hono
    , never from
    /react
    .
hono/jsx
是 JSX 运行时。不要引入 React:
  • 不要安装
    react
    、
    react-dom
    、
    @types/react
    或
    @vitejs/plugin-react
    。不要从
    react
    导入内容。
  • tsconfig.json
    中包含
    "jsx": "react-jsx"
    和
    "jsxImportSource": "hono/jsx"
    。
    react-jsx
    是转换器名称,并不表示依赖 React。
  • 服务端组件是普通函数,每次请求渲染一次。服务端没有 Hooks、state 或 event handlers。需要数据时,将组件设为
    async
    并使用
    await
    。需要交互性时,仅为对应部分使用基于
    hono/jsx/dom
    的客户端组件。
  • 使用
    class
    ,不要使用
    className
    。
  • vite-ssr-components
    同时导出两种形式:请从
    vite-ssr-components/hono
    导入,绝不要从
    /react
    导入。

Project Layout (Cloudflare Workers + Vite)

项目布局(Cloudflare Workers + Vite)

This is what
create-hono
generates with the
cloudflare-workers+vite
template. Match it in existing projects instead of inventing a different structure.
src/
  index.tsx      # Hono app, routes
  renderer.tsx   # jsxRenderer layout: <html>, <head>, Vite assets
  client.ts      # optional: browser-side code
  style.css
public/          # static files served as-is
vite.config.ts
wrangler.jsonc
jsonc
// package.json (scripts)
{
  "dev": "vite",
  "build": "vite build",
  "preview": "$npm_execpath run build && vite preview",
  "deploy": "$npm_execpath run build && wrangler deploy",
  "cf-typegen": "wrangler types --env-interface CloudflareBindings"
}
ts
// vite.config.ts
import { cloudflare } from '@cloudflare/vite-plugin'
import { defineConfig } from 'vite'
import ssrPlugin from 'vite-ssr-components/plugin'

export default defineConfig({
  plugins: [cloudflare(), ssrPlugin()],
})
tsx
// src/renderer.tsx
import { jsxRenderer } from 'hono/jsx-renderer'
import { Link, Script, ViteClient } from 'vite-ssr-components/hono'

export const renderer = jsxRenderer(({ children }) => {
  return (
    <html>
      <head>
        <ViteClient />
        <Link href="/src/style.css" rel="stylesheet" />
        <Script src="/src/client.ts" />
      </head>
      <body>{children}</body>
    </html>
  )
})
tsx
// src/index.tsx
import { Hono } from 'hono'
import { renderer } from './renderer'

const app = new Hono<{ Bindings: CloudflareBindings }>()

app.use(renderer)

app.get('/', (c) => {
  return c.render(<h1>Hello!</h1>)
})

export default app
vite-ssr-components
does the Vite plumbing:
<ViteClient />
injects the Vite client and SSR hot reload in dev and renders nothing in production;
<Script>
and
<Link>
point at source files and are rewritten to the hashed build output via the manifest. The plugin scans for them and adds each referenced file as a build entry, so there is no
rollupOptions.input
to maintain. Keep
<Script>
and
<Link>
as the component names, or configure the plugin's
components
option.
Dev server is
npm run dev
(Vite with the Cloudflare plugin, so
c.env
bindings are real). Bindings types come from
npm run cf-typegen
; never hand-write them.
这是
create-hono
使用
cloudflare-workers+vite
模板生成的结构。现有项目应与该结构保持一致,不要另创一套布局。
src/
  index.tsx      # Hono app, routes
  renderer.tsx   # jsxRenderer layout: <html>, <head>, Vite assets
  client.ts      # optional: browser-side code
  style.css
public/          # static files served as-is
vite.config.ts
wrangler.jsonc
jsonc
// package.json (scripts)
{
  "dev": "vite",
  "build": "vite build",
  "preview": "$npm_execpath run build && vite preview",
  "deploy": "$npm_execpath run build && wrangler deploy",
  "cf-typegen": "wrangler types --env-interface CloudflareBindings"
}
ts
// vite.config.ts
import { cloudflare } from '@cloudflare/vite-plugin'
import { defineConfig } from 'vite'
import ssrPlugin from 'vite-ssr-components/plugin'

export default defineConfig({
  plugins: [cloudflare(), ssrPlugin()],
})
tsx
// src/renderer.tsx
import { jsxRenderer } from 'hono/jsx-renderer'
import { Link, Script, ViteClient } from 'vite-ssr-components/hono'

export const renderer = jsxRenderer(({ children }) => {
  return (
    <html>
      <head>
        <ViteClient />
        <Link href="/src/style.css" rel="stylesheet" />
        <Script src="/src/client.ts" />
      </head>
      <body>{children}</body>
    </html>
  )
})
tsx
// src/index.tsx
import { Hono } from 'hono'
import { renderer } from './renderer'

const app = new Hono<{ Bindings: CloudflareBindings }>()

app.use(renderer)

app.get('/', (c) => {
  return c.render(<h1>Hello!</h1>)
})

export default app
vite-ssr-components
负责 Vite 资源管线:
<ViteClient />
在开发环境中注入 Vite client 和 SSR 热更新,在生产环境中不渲染任何内容;
<Script>
和
<Link>
指向源文件,并通过 manifest 重写为带哈希的构建输出。插件会扫描这些组件,将每个被引用的文件添加为构建入口,因此无需维护
rollupOptions.input
。请保留
<Script>
和
<Link>
作为组件名称,或者配置插件的
components
选项。
开发服务器通过
npm run dev
启动(使用带 Cloudflare 插件的 Vite,因此
c.env
bindings 是真实可用的)。Bindings 类型由
npm run cf-typegen
生成;绝不要手动编写。

Layouts and Pages

布局与页面

jsxRenderer
sets the layout; handlers call
c.render()
with the page content.
  • Per-page head content: write
    <title>
    ,
    <meta>
    , and
    <link>
    inside the page component. They are hoisted into
    <head>
    . Existing head elements stay, so keep the layout's
    <title>
    out if pages set their own.
  • Props to the layout (a title, a current-nav key): extend
    ContextRenderer
    once, then pass the props as the second argument of
    c.render()
    .
tsx
declare module 'hono' {
  interface ContextRenderer {
    (content: string | Promise<string>, props: { title: string }): Response
  }
}

export const renderer = jsxRenderer(({ children, title }) => (
  <html>
    <head>
      <title>{title}</title>
    </head>
    <body>{children}</body>
  </html>
))

app.get('/about', (c) => c.render(<About />, { title: 'About' }))
  • Nested layouts: a sub-app can register its own
    jsxRenderer
    and receive the parent as
    Layout
    .
tsx
const blog = new Hono()
blog.use(
  jsxRenderer(({ children, Layout }) => (
    <Layout>
      <nav>Blog</nav>
      {children}
    </Layout>
  ))
)
app.route('/blog', blog)
  • Request data inside components:
    useRequestContext()
    from
    hono/jsx-renderer
    returns
    c
    , so a deep component can read
    c.req
    ,
    c.env
    , or
    c.var
    without prop drilling. Prefer loading data in the handler and passing props when the data is only needed at one place.
tsx
import { useRequestContext } from 'hono/jsx-renderer'

const UserMenu = async () => {
  const c = useRequestContext<{ Bindings: CloudflareBindings }>()
  const user = await c.env.KV.get('user')
  return <span>{user}</span>
}
  • Shared components: put reusable pieces in
    src/components/
    . They are functions taking props;
    FC<Props>
    and
    PropsWithChildren<Props>
    from
    hono/jsx
    type them.
jsxRenderer
用于设置布局;handler 通过
c.render()
传入页面内容。
  • 每页的 head 内容:在页面组件内写入
    <title>
    、
    <meta>
    和
    <link>
    。它们会被提取到
    <head>
    中。已有的 head 元素会保留,因此如果页面自行设置
    <title>
    ,请不要在布局中保留
    <title>
    。
  • 向布局传递属性(标题、当前导航项等):扩展一次
    ContextRenderer
    ,然后将这些属性作为
    c.render()
    的第二个参数传入。
tsx
declare module 'hono' {
  interface ContextRenderer {
    (content: string | Promise<string>, props: { title: string }): Response
  }
}

export const renderer = jsxRenderer(({ children, title }) => (
  <html>
    <head>
      <title>{title}</title>
    </head>
    <body>{children}</body>
  </html>
))

app.get('/about', (c) => c.render(<About />, { title: 'About' }))
  • 嵌套布局:子应用可以注册自己的
    jsxRenderer
    ,并通过
    Layout
    接收父布局。
tsx
const blog = new Hono()
blog.use(
  jsxRenderer(({ children, Layout }) => (
    <Layout>
      <nav>Blog</nav>
      {children}
    </Layout>
  ))
)
app.route('/blog', blog)
  • 组件内访问请求数据:
    hono/jsx-renderer
    中的
    useRequestContext()
    会返回
    c
    ,因此深层组件无需逐层传递 props 即可读取
    c.req
    、
    c.env
    或
    c.var
    。如果数据只在一处使用,优先在 handler 中加载数据并通过 props 传递。
tsx
import { useRequestContext } from 'hono/jsx-renderer'

const UserMenu = async () => {
  const c = useRequestContext<{ Bindings: CloudflareBindings }>()
  const user = await c.env.KV.get('user')
  return <span>{user}</span>
}
  • 共享组件:将可复用部分放在
    src/components/
    中。它们是接收 props 的函数;可使用
    hono/jsx
    中的
    FC<Props>
    和
    PropsWithChildren<Props>
    进行类型标注。

Forms and Mutations

表单与数据变更

The default is an HTML form posting to a Hono route and a redirect. No client JavaScript is required, and it works before any script loads.
tsx
app.get('/todos', async (c) => {
  const todos = await listTodos(c.env)
  return c.render(
    <>
      <form method="post" action="/todos">
        <input name="title" required />
        <button type="submit">Add</button>
      </form>
      <ul>{todos.map((t) => <li>{t.title}</li>)}</ul>
    </>
  )
})

app.post('/todos', async (c) => {
  const body = await c.req.parseBody()
  await addTodo(c.env, String(body.title))
  return c.redirect('/todos')
})
Validate with
validator('form', ...)
or a schema validator from the
hono
skill; on failure re-render the page with the error message rather than returning JSON.
默认方案是使用 HTML form 将数据提交到 Hono route,然后执行重定向。这样无需客户端 JavaScript,并且在任何脚本加载前都能正常工作。
tsx
app.get('/todos', async (c) => {
  const todos = await listTodos(c.env)
  return c.render(
    <>
      <form method="post" action="/todos">
        <input name="title" required />
        <button type="submit">Add</button>
      </form>
      <ul>{todos.map((t) => <li>{t.title}</li>)}</ul>
    </>
  )
})

app.post('/todos', async (c) => {
  const body = await c.req.parseBody()
  await addTodo(c.env, String(body.title))
  return c.redirect('/todos')
})
使用
validator('form', ...)
或
hono
skill 中的 schema validator 进行验证;验证失败时,重新渲染页面并显示错误消息,而不是返回 JSON。

Client-Side Behavior

客户端行为

Most pages need no client JavaScript. When a part does, start with plain DOM code and reach for
hono/jsx/dom
only for UI that holds state and re-renders. Neither involves React.
Plain script — the default. Put it in
src/client.ts
and load it from the renderer with
<Script src="/src/client.ts" />
.
ts
// src/client.ts
/// <reference lib="dom" />
document.querySelector('#menu-toggle')?.addEventListener('click', () => {
  document.querySelector('#menu')?.classList.toggle('open')
})
hono/jsx/dom
— for a stateful part such as a counter, a live filter, or a multi-step form. The server renders an empty container; the client renders into it with
render()
. There is no hydration, so that part is empty until the script runs. Keep the page's actual content server-rendered.
tsx
// src/client.tsx
/// <reference lib="dom" />
import { useState } from 'hono/jsx'
import { render } from 'hono/jsx/dom'

function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

render(<Counter />, document.getElementById('counter')!)
Hooks (
useState
,
useEffect
,
useRef
, ...) have React-compatible signatures and are imported from
hono/jsx
. Calling the server from the client is a
fetch
to a JSON route;
hc
from
hono/client
adds typed calls once there is an API surface worth typing.
Type-checking client files. The template's
tsconfig.json
has
lib: ["ESNext"]
and no
DOM
, so
document
fails
tsc
. Put a lib reference at the top of each client file instead of editing
tsconfig.json
:
ts
/// <reference lib="dom" />
Vite does not read
lib
, so this only matters for
tsc
.
大多数页面都不需要客户端 JavaScript。确实需要时,先使用原生 DOM 代码,只有需要保存状态并重新渲染的 UI 才使用
hono/jsx/dom
。两者都不涉及 React。
原生脚本——默认方案。将其放在
src/client.ts
中,并通过 renderer 中的
<Script src="/src/client.ts" />
加载。
ts
// src/client.ts
/// <reference lib="dom" />
document.querySelector('#menu-toggle')?.addEventListener('click', () => {
  document.querySelector('#menu')?.classList.toggle('open')
})
hono/jsx/dom
——适用于计数器、实时筛选器或多步表单等有状态部分。服务端渲染一个空容器;客户端通过
render()
渲染到其中。不存在 hydration,因此该部分在脚本运行前保持为空。页面的实际内容应保持服务端渲染。
tsx
// src/client.tsx
/// <reference lib="dom" />
import { useState } from 'hono/jsx'
import { render } from 'hono/jsx/dom'

function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

render(<Counter />, document.getElementById('counter')!)
Hooks(
useState
、
useEffect
、
useRef
等)具有与 React 兼容的签名,并从
hono/jsx
导入。客户端调用服务器时,应对 JSON route 发起
fetch
请求;当 API surface 值得进行类型标注后,可以使用
hono/client
中的
hc
添加类型化调用。
客户端文件的类型检查。 模板的
tsconfig.json
中包含
lib: ["ESNext"]
,没有
DOM
,因此
document
无法通过
tsc
类型检查。请在每个客户端文件顶部添加 lib reference,不要修改
tsconfig.json
:
ts
/// <reference lib="dom" />
Vite 不读取
lib
,因此这只影响
tsc
。

Styling

样式

  • A stylesheet at
    src/style.css
    linked with
    <Link href="/src/style.css" rel="stylesheet" />
    in the renderer. Vite processes it, so CSS imports and PostCSS work.
  • hono/css
    for component-scoped styles:
    css
    returns a class name,
    <Style />
    in
    <head>
    emits the collected rules.
tsx
import { css, Style } from 'hono/css'

const card = css`
  padding: 1rem;
  &:hover { background: #eee; }
`
// <head><Style /></head> ... <div class={card}>...</div>
  • 在
    src/style.css
    中放置样式表,并通过 renderer 中的
    <Link href="/src/style.css" rel="stylesheet" />
    引用。Vite 会处理该文件,因此 CSS imports 和 PostCSS 均可正常使用。
  • 使用
    hono/css
    实现组件作用域样式:
    css
    返回一个 class name,
    <head>
    中的
    <Style />
    会输出收集到的规则。
tsx
import { css, Style } from 'hono/css'

const card = css`
  padding: 1rem;
  &:hover { background: #eee; }
`
// <head><Style /></head> ... <div class={card}>...</div>

Streaming

流式渲染

For a page where one part is slow, keep the shell instant: enable
stream: true
on the renderer and wrap the slow async component in
Suspense
.
tsx
import { Suspense } from 'hono/jsx/streaming'

export const renderer = jsxRenderer(({ children }) => <html><body>{children}</body></html>, { stream: true })

app.get('/', (c) =>
  c.render(
    <Suspense fallback={<p>Loading...</p>}>
      <SlowList />
    </Suspense>
  )
)
Suspense
and
ErrorBoundary
are marked experimental in the docs. Use them for genuinely slow data, not by default.
对于某个部分加载较慢的页面,应保持页面 shell 即时呈现:在 renderer 上启用
stream: true
,并使用
Suspense
包裹速度较慢的异步组件。
tsx
import { Suspense } from 'hono/jsx/streaming'

export const renderer = jsxRenderer(({ children }) => <html><body>{children}</body></html>, { stream: true })

app.get('/', (c) =>
  c.render(
    <Suspense fallback={<p>Loading...</p>}>
      <SlowList />
    </Suspense>
  )
)
文档中将
Suspense
和
ErrorBoundary
标记为 experimental。仅在数据确实较慢时使用,不要默认启用。

Escaping and Raw HTML

转义与原始 HTML

JSX escapes interpolated strings. To insert trusted HTML, use
dangerouslySetInnerHTML={{ __html }}
or
raw()
from
hono/html
. Never pass user input through either.
JSX 会转义插入的字符串。要插入受信任的 HTML,请使用
dangerouslySetInnerHTML={{ __html }}
或
hono/html
中的
raw()
。绝不要将用户输入传给二者中的任何一个。

Other Runtimes

其他运行时

Without the Cloudflare Vite plugin, use
@hono/vite-dev-server
for dev and
@hono/vite-build/<runtime>
for the build. Client assets are then built in a separate Vite mode and referenced by fixed paths:
ts
// vite.config.ts
import build from '@hono/vite-build/node' // or /bun, /cloudflare-workers
import devServer from '@hono/vite-dev-server'
import { defineConfig } from 'vite'

export default defineConfig(({ mode }) => {
  if (mode === 'client') {
    return {
      build: { rollupOptions: { input: './src/client.tsx', output: { entryFileNames: 'static/client.js' } } },
    }
  }
  return {
    plugins: [
      // staticRoot is a Node adapter option: it serves dist/static from the built server
      build({ entry: 'src/index.tsx', staticRoot: './dist' }),
      devServer({ entry: 'src/index.tsx' }),
    ],
  }
})
tsx
// renderer: pick the script path by mode
{import.meta.env.PROD ? (
  <script type="module" src="/static/client.js" />
) : (
  <script type="module" src="/src/client.tsx" />
)}
Build with
vite build --mode client && vite build
, then run
node dist/index.js
(the Node build bundles
@hono/node-server
, install it as a dependency).
@hono/vite-dev-server
also has adapters (
/cloudflare
,
/node
,
/bun
) when bindings or runtime APIs are needed in dev.
不使用 Cloudflare Vite 插件时,开发使用
@hono/vite-dev-server
,构建使用
@hono/vite-build/<runtime>
。此时客户端资源通过单独的 Vite mode 构建,并使用固定路径引用:
ts
// vite.config.ts
import build from '@hono/vite-build/node' // or /bun, /cloudflare-workers
import devServer from '@hono/vite-dev-server'
import { defineConfig } from 'vite'

export default defineConfig(({ mode }) => {
  if (mode === 'client') {
    return {
      build: { rollupOptions: { input: './src/client.tsx', output: { entryFileNames: 'static/client.js' } } },
    }
  }
  return {
    plugins: [
      // staticRoot is a Node adapter option: it serves dist/static from the built server
      build({ entry: 'src/index.tsx', staticRoot: './dist' }),
      devServer({ entry: 'src/index.tsx' }),
    ],
  }
})
tsx
// renderer: pick the script path by mode
{import.meta.env.PROD ? (
  <script type="module" src="/static/client.js" />
) : (
  <script type="module" src="/src/client.tsx" />
)}
执行
vite build --mode client && vite build
,然后运行
node dist/index.js
。Node 构建会打包
@hono/node-server
,请将其作为依赖安装。当开发环境需要 bindings 或 runtime API 时,
@hono/vite-dev-server
也提供 adapters(
/cloudflare
、
/node
、
/bun
)。

Documentation

文档

Checklist

检查清单

  • No React packages;
    jsxImportSource
    is
    hono/jsx
    ;
    class
    attributes.
  • Layout in
    jsxRenderer
    , pages via
    c.render()
    , per-page
    <title>
    inside the page.
  • Forms post to routes and redirect; client JavaScript only where needed, plain DOM first.
  • Vite assets go through
    vite-ssr-components
    (
    ViteClient
    ,
    Script
    ,
    Link
    ), not hand-written
    <script>
    tags, on Cloudflare.
  • Bindings types from
    npm run cf-typegen
    .
  • 不使用 React 包;
    jsxImportSource
    为
    hono/jsx
    ;使用
    class
    属性。
  • 在
    jsxRenderer
    中定义布局,通过
    c.render()
    渲染页面,每页的
    <title>
    写在页面内部。
  • 表单提交到 route 后执行重定向;仅在必要位置使用客户端 JavaScript,并优先采用原生 DOM。
  • 在 Cloudflare 上,Vite 资源应通过
    vite-ssr-components
    (
    ViteClient
    、
    Script
    、
    Link
    )处理,不要手写
    <script>
    标签。
  • Bindings 类型由
    npm run cf-typegen
    生成。