Hono × Inertia(@hono/inertia)で API とクライアントルーターなしの React SPA を書く
Hono のルートで c.render('Posts/Index', { posts }) と返すだけで、app/pages/Posts/Index.tsx が props 付きで描画される。JSON 用の API、React Router、useEffect + fetch、取得データ用の useState が不要になり、ページ名と props の型もサーバーからクライアントまで通る。
書き方
tsx
// src/server.tsx(Hono)
app.get('/posts', (c) => c.render('Posts/Index', { posts: listPosts() }))
app.get('/posts/:id{[0-9]+}', (c) => {
const post = findPost(Number(c.req.param('id')))
if (!post) return c.notFound()
return c.render('Posts/Show', { post })
})
// app/pages/Posts/Show.tsx(React)
export default function Show({ post }: PageProps<'Posts/Show'>) {
return <article><h1>{post.title}</h1><p>{post.body}</p></article>
}@hono/inertia/viteプラグインがapp/pages/を監視してpages.gen.tsを生成するので、c.renderのページ名はファイルと一致しないと型エラーになり、補完も効く。PageProps<'Posts/Show'>でサーバーが渡した props の型がそのまま届く。- 機能追加で触るのは「Hono のルート」と「
app/pages/<Domain>/」の2か所だけ。
バリデーションは zod スキーマ1つから
ts
export const postInputSchema = z.object({
title: z.string().min(1, 'タイトルは必須です').max(80, 'タイトルは80文字以内で入力してください'),
body: z.string().min(1, '本文は必須です').max(2000),
})
export type PostInput = z.infer<typeof postInputSchema> // ① 型
app.post('/posts',
zValidator('json', postInputSchema, (result, c) => { // ② 実行時チェック
if (!result.success) {
// 422 を返すのではなく、同じフォームを入力値とエラー付きで描き直す
return c.render('Posts/New', {
values: recoverInput(result.data),
errors: toFieldErrors(result.error), // ③ エラー文言がそのまま props に
})
}
}),
(c) => {
const post = createPost(c.req.valid('json'))
return c.redirect(`/posts/${post.id}`, 303)
},
)クライアントは res.status === 422 の分岐も state 復元も書かない。useForm(values) でサーバーが返した値から初期化すれば入力も消えない(Rails の render :new と同じ発想)。
向いているもの / 注意
- CRUD 中心の管理画面や社内ツールなど「サーバーが画面遷移を決めてよい」アプリに向く。オフライン対応や複雑なクライアント状態が主役のアプリには別の構成が合う。
recoverInput/toFieldErrorsは元記事のサンプル側のヘルパー。正確な API は@hono/inertiaの README とサンプルリポジトリで確認する。- 試すなら github.com を
bun install && bun run devで動かすのが早い。
出典
- @yusukebe 2026-04-29(紹介): @yusukebe · 2026-04-29
- @ashunar0 2026-04-28(型の仕組み): @ashunar0 · 2026-04-28
- 元記事(Zenn): zenn.dev / zenn.dev