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.
No 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 fromreact. tsconfig.jsonhas"jsx": "react-jsx"and"jsxImportSource": "hono/jsx". Thereact-jsxvalue 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
asyncandawait. Need interactivity: a client component withhono/jsx/dom, for that part only. - Write
class, notclassName. vite-ssr-componentsexports both flavors: import fromvite-ssr-components/hono, never from/react.
Project Layout (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
// 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"
}
// 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()],
})
// 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>
)
})
// 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.
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
ContextRendereronce, then pass the props as the second argument ofc.render().
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
jsxRendererand receive the parent asLayout.
const blog = new Hono()
blog.use(
jsxRenderer(({ children, Layout }) => (
<Layout>
<nav>Blog</nav>
{children}
</Layout>
))
)
app.route('/blog', blog)
- Request data inside components:
useRequestContext()fromhono/jsx-rendererreturnsc, so a deep component can readc.req,c.env, orc.varwithout prop drilling. Prefer loading data in the handler and passing props when the data is only needed at one place.
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>andPropsWithChildren<Props>fromhono/jsxtype them.
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.
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.
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" />.
// 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.
// 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:
/// <reference lib="dom" />
Vite does not read lib, so this only matters for tsc.
Styling
- A stylesheet at
src/style.csslinked with<Link href="/src/style.css" rel="stylesheet" />in the renderer. Vite processes it, so CSS imports and PostCSS work. hono/cssfor component-scoped styles:cssreturns a class name,<Style />in<head>emits the collected rules.
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.
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.
Escaping and Raw HTML
JSX escapes interpolated strings. To insert trusted HTML, use dangerouslySetInnerHTML={{ __html }} or raw() from hono/html. Never pass user input through either.
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:
// 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' }),
],
}
})
// 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.
Documentation
Fetch with Accept: text/markdown (see the hono skill):
- https://hono.dev/docs/guides/jsx
- https://hono.dev/docs/guides/jsx-dom
- https://hono.dev/docs/middleware/builtin/jsx-renderer
- https://hono.dev/docs/helpers/css
- https://hono.dev/docs/helpers/html
- https://github.com/yusukebe/vite-ssr-components
- https://github.com/honojs/vite-plugins (dev-server, build)
Checklist
- No React packages;
jsxImportSourceishono/jsx;classattributes. - Layout in
jsxRenderer, pages viac.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.