Hono + Inertia (React)¶
Node.js / Bun Inertia.js サーバ駆動 Vite+ alpha
Hono のルートが @hono/inertia ミドルウェア経由で c.render(component, props) を呼び出し、クライアント側では @inertiajs/react v3 が対応する React ページをマウントするサーバドリブン SPA。REST/GraphQL レイヤーは存在せず、コントローラがそのままページコンポーネントへ props を渡します。
このテンプレートは Vite+ (vite-plus) — VoidZero が提供する Vite / Vitest / Oxlint / Oxfmt / Rolldown を単一の vp CLI 配下にまとめた統合ツールチェイン — を使用します。SSR がデフォルトでオン で、src/Ssr.res が react-dom/server の renderToString を使って各ページをサーバ側でレンダリングし、生成された HTML を <div id="app">…</div> の中に埋め込み、ブラウザ側エントリは hydrateRoot で hydration を行います。以降の Inertia ナビゲーション (X-Inertia: true) は JSON のみを返し、SSR を経由しません。
生成内容¶
my-project/
├── rescript.json
├── package.json # vp dev / vp build / vp test / vp check
├── vite.config.mjs # vite-plus + react + inertiaPages plugins
├── drizzle.config.ts # drizzle-kit config (reads Schema.res.mjs)
├── index.html # Inertia HTML host page (#app + data-page)
├── src/
│ ├── Server.res # Hono + HonoInertia.inertia() + Routes.Pages.register
│ ├── ServerMain.res # Production entry — calls Server.start()
│ ├── Routes.res # GET / (Home), GET /about, POST /greet
│ ├── HonoInertia.res # @hono/inertia middleware bindings
│ ├── InertiaBindings.res # @inertiajs/react bindings (Link / usePage / createInertiaApp)
│ ├── Schema.res # Drizzle SQLite schema (posts table)
│ ├── Db.res # libsql + Drizzle helpers
│ ├── Validation.res # zod or sury — selected in the wizard
│ ├── Logger.res # hono/logger
│ ├── Hono.res # Hono bindings
│ ├── HonoNodeServer.res # @hono/node-server bindings
│ ├── client/
│ │ ├── Main.res # createInertiaApp entry
│ │ ├── pages.js # JS shim — import.meta.glob page resolver
│ │ ├── MainLayout.res # Shared chrome (uses InertiaBindings.usePage)
│ │ └── Pages/
│ │ ├── Home.res # @react.component Home page
│ │ └── About.res # @react.component About page
│ └── __tests__/
│ └── Server.test.mjs # vitest hits app.request — both HTML + Inertia JSON paths
├── README.md # API / Frontend / Project Layout / About Vite+
├── LICENSE # MIT, holder = project name
├── .env.example # documents DATABASE_URL
├── .nvmrc # Node 24
├── .gitignore # node_modules, dist/, data/, drizzle/, .vite/, .env
├── .editorconfig
└── .github/
├── dependabot.yml
└── workflows/ci.yml
ウィザードオプション¶
オプション |
効果 |
|---|---|
プロジェクト名 |
npm の |
パッケージマネージャ |
npm / pnpm / yarn / bun から選択。 |
バリデーションライブラリ |
|
主要な依存¶
パッケージ |
用途 |
バージョン |
|---|---|---|
|
ReScript コンパイラ |
|
|
標準ライブラリ |
|
|
コンパイル後の |
|
|
React JSX バインディング |
|
|
React ランタイム ( |
|
|
HTTP ルーター |
|
|
Hono 向け Node アダプタ |
|
|
|
|
|
Inertia クライアント + |
|
|
SQLite ドライバ (Turso の |
|
|
型安全な SQL ビルダ |
|
|
フォーム入力のバリデーション (ウィザードで選択) |
|
|
下層のバンドラ — Vite+ → Vite フォールバックのため直接の依存として保持 |
|
|
Vite+ 統合 CLI ( |
|
|
Vite+ ランタイムヘルパ |
|
|
React Fast Refresh と JSX サポート |
|
|
マイグレーションの生成・実行 |
|
補足: vitest、eslint、prettier は 意図的に同梱していません — Vite+ が vp test / vp check の内側でこれらをまとめて提供します。
主要なファイル¶
src/Server.res¶
ミドルウェアの登録順序が要です。c->HonoInertia.render(...) を呼び出すルートよりも 前に inertia() を登録する必要があります。
let app = Hono.createApp()
app->Hono.use(Logger.logger())
app->Hono.use(HonoInertia.inertia())
app->Hono.onError((err, ctx) => {
Console.error(err)
ctx->Hono.status(500)->Hono.json({"error": "Internal Server Error"})
})
app->Hono.get("/health", ctx => ctx->Hono.json({"status": "ok"}))
Routes.Pages.register(app)
let start = () => {
HonoNodeServer.serve({fetch: app->HonoNodeServer.honoFetch, port: 3000})
}
Server.res はモジュールのトップレベルで serve() を呼び出しません。実際の起動処理は start() の中にあり、ServerMain.res から呼び出されます。これによりテストはポート 3000 を bind せずに import("../Server.res.mjs") できます。
src/Routes.res¶
各 Inertia ルートは名前で React ページを指定し、型付きの props を渡します:
app->Hono.get("/", async ctx => {
await ctx->HonoInertia.render(
"Home",
{"title": "Home", "message": "Hono renders React pages directly through Inertia."},
)
})
Inertia はリクエストの X-Inertia ヘッダを見分けます: 付与あり → JSON ページエンベロープ、付与なし → HTML ホストページ (deep link やアドレスバー直打ちが動く)。
src/HonoInertia.res¶
@module("@hono/inertia")
external inertia: unit => Hono.middleware = "inertia"
@send
external render: (Hono.context, string, 'props) => promise<'response> = "render"
このミドルウェアは Hono 既存の c.render をオーバーライドします。そのため render はテンプレートの他の箇所で使われているのと同じ context 型に対する @send external として束ねています。
src/InertiaBindings.res¶
@inertiajs/react v3 に対する最小限のバインディング:
@module("@inertiajs/react")
external createInertiaApp: appOptions => unit = "createInertiaApp"
module Link = {
@module("@inertiajs/react") @react.component
external make: (~href: string, ~method: string=?, ~children: React.element=?, ...) => React.element = "Link"
}
@module("@inertiajs/react")
external usePage: unit => page<'props> = "usePage"
MainLayout.res は usePage() を呼び出して page.url を読み取ります。各ページでは プレーンな <a> タグの代わりに <InertiaBindings.Link href="/about"> を使うことで、Inertia がクリックをインターセプトできるようにします。
src/client/pages.js — JS シム¶
ReScript 12 の Js.import は import パスを 静的に 解決します — import("./Pages/" ++ name + ".res.mjs") はコンパイラに拒否されます。import.meta.glob は Vite 専用の拡張で、ビルド時に遅延ローダーのテーブルを作ります。そのため、リゾルバは素の JS のまま残しています:
const pages = import.meta.glob("./Pages/**/*.res.mjs");
export async function resolvePage(name) {
const path = `./Pages/${name}.res.mjs`;
const loader = pages[path];
if (!loader) throw new Error(`Inertia page not found: ${name}`);
const mod = await loader();
return { default: mod.make ?? mod.default ?? mod };
}
Inertia は resolve が返す値の .default を読み取ります。ReScript がコンパイルしたモジュールは React コンポーネントを make として公開するため、シムが default の下に再ラップします。
src/client/Main.res¶
@module("./pages.js")
external resolvePage: string => promise<{..}> = "resolvePage"
@module("react-dom/client")
external hydrateRoot: (Dom.element, React.element) => unit = "hydrateRoot"
InertiaBindings.createInertiaApp({
resolve: resolvePage,
setup: ({el, app, props}) => {
hydrateRoot(el, React.createElement(app, props))
},
})
src/Ssr.res¶
@module("react-dom/server")
external renderToString: React.element => string = "renderToString"
module InertiaApp = {
@module("@inertiajs/react") @react.component
external make: (
~initialPage: HonoInertia.pageObject,
~initialComponent: 'component,
~resolveComponent: string => 'component,
) => React.element = "App"
}
external castComponent: 'a => 'b = "%identity"
let resolveComponent = (name: string) =>
switch name {
| "Home" => castComponent(Home.make)
| "About" => castComponent(About.make)
| other => Js.Exn.raiseError(`Inertia SSR: unknown page "${other}"`)
}
let renderInertia = (page: HonoInertia.pageObject) => {
let initialComponent = resolveComponent(page.component)
let body = renderToString(
<InertiaApp initialPage=page initialComponent resolveComponent />,
)
{head: [], body}
}
ページは Inertia の React <App> を介してレンダリングされるため、MainLayout から呼ばれる usePage() が provider コンテキストを見つけられます。resolveComponent は同期で動くので、renderToString も rootView も同期のままで済みます。新しいページを追加する際は、client/Pages/ と Routes.res だけでなくこの switch も更新する必要があります。明示的なレジストリにすることで、登録漏れが ランタイムの 500 ではなくコンパイル時のエラーとして検出されます。
vite.config.mjs¶
import { defineConfig } from "vite-plus";
import react from "@vitejs/plugin-react";
import { inertiaPages } from "@hono/inertia/vite";
export default defineConfig({
plugins: [react(), inertiaPages()],
server: {
proxy: {
"^/(?!@vite|src|node_modules|@id|@fs).*": {
target: "http://localhost:3000",
changeOrigin: true,
},
},
},
});
このプロキシは開発時に Inertia の GET/POST リクエストを Hono サーバへ転送します。静的アセットと HMR は Vite+ がそのまま提供します。本番では同じ Hono サーバが dist/ を配信し、React バンドルはただの静的アセットになります。
npm スクリプト¶
スクリプト |
説明 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
新しいページの追加¶
src/client/Pages/<Name>.resに新しいファイルを置きます:@react.component let make = (~title) => <MainLayout> <h1> {React.string(title)} </h1> </MainLayout>
src/Routes.resにルートを登録します:app->Hono.get("/the/path", async ctx => { await ctx->HonoInertia.render("<Name>", {"title": "Hello"}) })
pages.js は import.meta.glob 経由で新しいファイルを自動的に発見します — 中央のレジストリを編集する必要はありません。
注意点 — Vite+ alpha¶
Vite+ は現在 alpha リリース中です (執筆時点で 0.1.x)。CLI と設定インターフェースはまだ流動的です:
test/lint/fmtの設定キーはvite.config.mjsではまだ公開されていません。デフォルト設定で動作しますが、今後配置が変わる可能性があります。0.1.x のパッチリリース間で API 変更が起こりえます。
Vite+ のリリースでこのテンプレートが壊れた場合、以下の手順で素の Vite にフォールバックできます:
vite.config.mjsのvite-plusをviteに置換 (import { defineConfig } from "vite")。package.jsonのvp ...スクリプトをvite/vitest runに直接置換。Vite+ がカバーしていた領域を埋めるため、
vitest、eslint、prettier(とそれらのプラグイン) を devDependencies として追加。
Hono、Inertia、Drizzle は Vite+ に依存していないため、そのまま使えます。
2日目以降のレシピ¶
Hono エンドポイントの追加 — Inertia ページと並べて REST ルートを追加 (webhook 受信、ヘルスチェックなど)
Drizzle のセットアップ — Drizzle スキーマ、マイグレーション、Turso への切り替え
プロジェクトを開いた後の ReScript 側エディタワークフローは 機能概要 を参照してください。
補足¶
CSR のみ。 このテンプレートはクライアント側でページをレンダリングします。サーバサイドレンダリングはフォローアップで予定しています。初回描画の SEO が今すぐ必要であれば、
index.htmlに静的スケルトンを書くか、特定ルートだけvite build --ssrで pre-render してください。Server.resはモジュールのトップレベルでserve()を呼び出しません — その処理はstart()の中にあり、ServerMain.resから呼び出されます。これによりテストはポート 3000 を bind せずにimport("../Server.res.mjs")できます。Inertia ミドルウェアの順序が重要。
c->HonoInertia.render(...)を呼ぶルートよりも前にHonoInertia.inertia()を登録する必要があります。同梱のServer.resはコメントでその制約を明示しています。pages.jsシムは意図的に同梱しています。 これは同梱できる最小限の JS (import.meta.glob周りの 5 行) であり、これによりテンプレートの他の部分を純粋な ReScript のまま保てます。既定ではローカル SQLite ファイル。
DATABASE_URL=file:./data/app.db。コードに手を加えずにlibsql://URL (Turso) に差し替えできます。React 19 が必須。
@inertiajs/reactv3 はreact ^19を peer dependency として宣言しています。@rescript/reactは React 19 で動作します。生成成果物は gitignore 済み:
dist/、data/、drizzle/、.vite/、.env。