About hono-jsx
hono-jsx is a skill guide for developers building interfaces with Hono’s JSX runtime. It emphasizes server-rendered pages and explains how to structure an app while adding client-side behavior only where needed.
Key features
- 01Server-rendered page structure
- 02Cloudflare Workers and Vite setup
- 03Layout, page, and component organization
- 04Form submission and route handling
- 05Optional client-side interaction
- 06Streaming and styling guidance
Use cases
- 01Structuring a Hono JSX application
- 02Configuring Vite in a Cloudflare Workers project
- 03Building forms without client scripts
- 04Adding interactive state to selected UI parts
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ honojs/ skills --skill hono-jsxAfter installing, type/hono-jsxin Claude Code to confirm it loaded.
Details
From the project READMEHono 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)
Start from what create-hono generates with the cloudflare-workers template (npm create hono@next, or npx hono init --template cloudflare-workers in an existing directory): src/index.ts with a text response, cloudflare.config.ts for the cf CLI, and a vite.config.ts with only cloudflare(). It has no JSX setup; add the pieces below, and match this layout in existing projects instead of inventing a different structure. Verify routes with the Hono CLI from the hono skill (npx hono request /); it is a dev dependency of the template and runs the app through Vite, so c.env has the local bindings automatically.
src/
index.tsx # Hono app, routes (renamed from index.ts)
renderer.tsx # jsxRenderer layout: <html>, <head>, Vite assets
client.ts # optional: browser-side code
style.css
public/ # static files served as-is
cloudflare.config.ts # cf CLI: worker name, entrypoint, bindings
vite.config.ts
// package.json (scripts, from the template)
{
"dev": "cf dev",
"build": "cf build",
"deploy": "cf deploy",
"typecheck": "cf workers types && tsc"
}
On top of the template:
-
Rename
src/index.tstosrc/index.tsxand point the entrypoint import incloudflare.config.tsat it:import * as entrypoint from './src/index.tsx' with { type: 'cf-worker' } -
Install
vite-ssr-componentsas a dev dependency and add its plugin:// 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()], }) -
Add
src/renderer.tsx,src/style.css, andpublic/:
// 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: Env }>()
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.
npm run dev (cf dev) is for humans; verify with the Hono CLI instead. Bindings are declared in cloudflare.config.ts, and their Env type comes from npm run typecheck; never hand-write it. In an existing wrangler project, keep wrangler, npm run cf-typegen, and CloudflareBindings as described in the hono skill.
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: Env }>()
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 generated, never hand-written:
Envfromnpm run typecheck(cf),CloudflareBindingsfromnpm run cf-typegen(wrangler).