mcpField
H
hono-jsx

hono-jsx

github.com/honojs開発16378 回インストール2 週間前に更新
リポジトリ

hono-jsx について

hono-jsxは、HonoのJSXランタイムでUIを構築する開発者向けのスキルガイドです。サーバーサイドレンダリングを基本とする構成を示し、必要な部分だけにクライアント側の動作を追加する方法を説明します。

主な機能

  1. 01サーバーサイドレンダリング中心のページ構成
  2. 02Cloudflare WorkersとViteの設定
  3. 03レイアウト、ページ、コンポーネントの構成
  4. 04フォーム送信とルート処理
  5. 05必要に応じたクライアント側の操作
  6. 06ストリーミングとスタイルの案内

活用シーン

  1. 01Hono JSXアプリの構成
  2. 02Cloudflare WorkersプロジェクトでのVite設定
  3. 03クライアントスクリプトを使わないフォームの実装
  4. 04一部のUIへの状態を持つ操作の追加

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/honojs/skills --skill hono-jsx

インストール後、Claude Code で/hono-jsxを入力して読み込まれたか確認します。

詳細

Hono JSX スキル

hono/jsx を使って UI を構築します。まずサーバーで HTML をレンダリングし、ブラウザーで状態が必要な部分に限って、少量のクライアントサイド JSX を使います。hono スキルでは JSX の構文と c.html() を扱います。このスキルではアプリの構成方法を扱います。デフォルトのセットアップは Vite を使った Cloudflare Workers です。それ以外の環境については「その他のランタイム」を参照してください。

React は使用しない

JSX ランタイムには hono/jsx を使います。React を追加しないでください。

  • react、react-dom、@types/react、@vitejs/plugin-react はインストールしないでください。react からインポートしないでください。
  • tsconfig.json では "jsx": "react-jsx" と "jsxImportSource": "hono/jsx" を指定します。react-jsx はトランスフォーム名であり、React への依存ではありません。
  • サーバーコンポーネントは、リクエストごとに一度だけレンダリングされる通常の関数です。サーバー上でフック、状態、イベントハンドラーは使いません。データが必要な場合は、コンポーネントを async にして await します。インタラクティブな機能が必要な場合は、その部分に限り hono/jsx/dom を使ったクライアントコンポーネントを使用します。
  • className ではなく class を使います。
  • vite-ssr-components は両方の種類をエクスポートします。vite-ssr-components/hono からインポートし、/react からはインポートしないでください。

プロジェクトの構成(Cloudflare Workers + Vite)

cloudflare-workers テンプレートを使って create-hono が生成する内容を基にします(npm create hono@next、または既存のディレクトリで npx hono init --template cloudflare-workers を実行)。テンプレートには、テキストレスポンスを返す src/index.ts、cf CLI 用の cloudflare.config.ts、cloudflare() のみを設定した vite.config.ts が含まれます。JSX の設定は含まれていないため、以下の項目を追加します。既存のプロジェクトでも、別の構成を考案せず、この構成に合わせてください。hono スキルにある Hono CLI を使ってルートを確認します(npx hono request /)。これはテンプレートの開発依存関係で、Vite 経由でアプリを実行するため、c.env にはローカルバインディングが自動的に設定されます。

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"
}

テンプレートに以下を追加します。

  1. src/index.ts を src/index.tsx に名前変更し、cloudflare.config.ts のエントリーポイントのインポート先も変更します。

    import * as entrypoint from './src/index.tsx' with { type: 'cf-worker' }
    
  2. vite-ssr-components を開発依存関係としてインストールし、そのプラグインを追加します。

    // 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()],
    })
    
  3. src/renderer.tsx、src/style.css、public/ を追加します。

// 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 が Vite の連携を処理します。<ViteClient /> は開発時に Vite クライアントと SSR のホットリロードを挿入し、本番環境では何もレンダリングしません。<Script> と <Link> はソースファイルを参照し、マニフェストを介してハッシュ付きのビルド出力を参照するように書き換えられます。プラグインはこれらをスキャンし、参照されている各ファイルをビルドエントリーとして追加するため、維持が必要な rollupOptions.input はありません。コンポーネント名は <Script> と <Link> のままにするか、プラグインの components オプションを設定してください。

npm run dev(cf dev)は人が使うためのものです。確認には Hono CLI を使ってください。バインディングは cloudflare.config.ts で宣言され、その Env 型は npm run typecheck から取得します。手動で記述しないでください。既存の wrangler プロジェクトでは、hono スキルの説明に従って wrangler、npm run cf-typegen、CloudflareBindings をそのまま使用してください。

レイアウトとページ

jsxRenderer でレイアウトを設定し、ハンドラーではページの内容を指定して c.render() を呼び出します。

  • ページごとの head コンテンツ:<title>、<meta>、<link> はページコンポーネント内に記述します。これらは <head> に移動されます。既存の head 要素はそのまま残るため、ページごとに独自の <title> を設定する場合は、レイアウトの <title> を省いてください。
  • レイアウトに渡す Props(タイトルや現在のナビゲーションキーなど):ContextRenderer を一度拡張し、c.render() の第 2 引数として Props を渡します。
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 として受け取れます。
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 を参照できます。データが 1 か所でしか必要ない場合は、ハンドラーでデータを読み込んで Props として渡す方法を優先してください。
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>
}
  • 共有コンポーネント:再利用可能な要素は src/components/ に配置します。これらは Props を受け取る関数です。hono/jsx の FC<Props> と PropsWithChildren<Props> で型を付けられます。

フォームとミューテーション

デフォルトでは、HTML フォームから Hono のルートに POST し、リダイレクトします。クライアント JavaScript は不要で、スクリプトが読み込まれる前でも動作します。

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 スキルのスキーマバリデーターで検証します。検証に失敗した場合は、JSON を返すのではなく、エラーメッセージを表示してページを再レンダリングします。

クライアント側の動作

ほとんどのページではクライアント JavaScript は不要です。必要な場合は、まずプレーンな DOM コードを使い、状態を保持して再レンダリングする UI に限って hono/jsx/dom を使用します。どちらも React は使いません。

プレーンなスクリプト — デフォルトの方法です。src/client.ts に記述し、レンダラーから <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 — カウンター、リアルタイムフィルター、複数ステップのフォームなど、状態を持つ部分に使用します。サーバーは空のコンテナーをレンダリングし、クライアントは render() でその中にレンダリングします。ハイドレーションはないため、スクリプトが実行されるまではその部分は空です。ページの実際のコンテンツはサーバーでレンダリングしてください。

// 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')!)

フック(useState、useEffect、useRef、...)のシグネチャは React と互換性があり、hono/jsx からインポートします。クライアントからサーバーを呼び出すには、JSON ルートへの fetch を使います。型付けする価値のある API ができたら、hono/client の hc を使うと、型付きの呼び出しを追加できます。

クライアントファイルの型チェック。 テンプレートの tsconfig.json では lib: ["ESNext"] が設定され、DOM は含まれていないため、document を使うと tsc でエラーになります。tsconfig.json を編集する代わりに、各クライアントファイルの先頭に lib 参照を記述してください。

/// <reference lib="dom" />

Vite は lib を読み込まないため、これは tsc にのみ影響します。

スタイル

  • レンダラーで <Link href="/src/style.css" rel="stylesheet" /> を使って読み込む、src/style.css のスタイルシート。Vite が処理するため、CSS のインポートと PostCSS が使えます。
  • コンポーネント単位のスタイルには hono/css を使います。css はクラス名を返し、<head> 内の <Style /> が収集したルールを出力します。
import { css, Style } from 'hono/css'

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

ストリーミング

一部の処理が遅いページでは、シェルをすぐに表示できるようにします。レンダラーで stream: true を有効にし、遅い非同期コンポーネントを 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 と ErrorBoundary はドキュメントで実験的機能として示されています。デフォルトでは使わず、実際にデータの取得が遅い場合に使用してください。

エスケープと生の HTML

JSX は補間された文字列をエスケープします。信頼できる HTML を挿入するには、dangerouslySetInnerHTML={{ __html }} または hono/html の raw() を使います。ユーザー入力をどちらにも渡してはいけません。

その他のランタイム

Cloudflare Vite プラグインを使用しない場合は、開発に @hono/vite-dev-server を、ビルドに @hono/vite-build/<runtime> を使います。その場合、クライアントアセットは別の Vite モードでビルドされ、固定パスで参照されます。

// 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" />
)}

vite build --mode client && vite build でビルドしてから、node dist/index.js を実行します(Node ビルドには @hono/node-server がバンドルされるため、依存関係としてインストールしてください)。@hono/vite-dev-server には、開発時にバインディングやランタイム API が必要な場合に使えるアダプター(/cloudflare、/node、/bun)もあります。

ドキュメント

Accept: text/markdown を指定して取得してください(hono スキルを参照)。

チェックリスト

  • React パッケージは使わず、jsxImportSource は hono/jsx にし、属性には class を使います。
  • レイアウトは jsxRenderer に置き、ページは c.render() でレンダリングし、ページごとの <title> はページ内に記述します。
  • フォームはルートに POST してリダイレクトします。クライアント JavaScript は必要な場合にのみ使い、まずプレーンな DOM を使用します。
  • Cloudflare では、Vite アセットの読み込みに vite-ssr-components(ViteClient、Script、Link)を使い、手書きの <script> タグは使いません。
  • バインディングの型は生成し、手書きしません。npm run typecheck(cf)で Env を、npm run cf-typegen(wrangler)で CloudflareBindings を生成します。

コメント