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 の name、ライセンス保有者、index.html の <title> に反映されます

パッケージマネージャ

npm / pnpm / yarn / bun から選択。packageManager フィールド、README の install/run コマンド、CI キャッシュキーに反映されます

バリデーションライブラリ

zod ↔ sury を切り替え。src/Validation.res の中身が選択肢に合わせて変わり、対応する依存も追加されます。どちらも同じ parseGreetForm シグネチャを公開します

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル後の .res.mjs が import するランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

@rescript/react

React JSX バインディング

TemplateVersions.RESCRIPT_REACT

react / react-dom

React ランタイム (@inertiajs/react v3 の peer)

TemplateVersions.REACT

hono

HTTP ルーター

TemplateVersions.HONO

@hono/node-server

Hono 向け Node アダプタ

TemplateVersions.HONO_NODE_SERVER

@hono/inertia

c.render をオーバーライドする Inertia ミドルウェア

TemplateVersions.HONO_INERTIA

@inertiajs/react

Inertia クライアント + <Link> / usePage / createInertiaApp

TemplateVersions.INERTIA_REACT

@libsql/client

SQLite ドライバ (Turso の libsql:// にも対応)

TemplateVersions.LIBSQL_CLIENT

drizzle-orm

型安全な SQL ビルダ

TemplateVersions.DRIZZLE_ORM

zod または sury

フォーム入力のバリデーション (ウィザードで選択)

TemplateVersions.ZOD / SURY

vite (dev)

下層のバンドラ — Vite+ → Vite フォールバックのため直接の依存として保持

TemplateVersions.VITE

vite-plus (dev)

Vite+ 統合 CLI (vp)

TemplateVersions.VITE_PLUS

@voidzero-dev/vite-plus-core (dev)

Vite+ ランタイムヘルパ

TemplateVersions.VITE_PLUS_CORE

@vitejs/plugin-react (dev)

React Fast Refresh と JSX サポート

TemplateVersions.VITEJS_PLUGIN_REACT

drizzle-kit (dev)

マイグレーションの生成・実行

TemplateVersions.DRIZZLE_KIT

補足: 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 スクリプト

スクリプト

説明

dev

vp dev — Vite+ の dev サーバを起動 (別シェルで node --watch src/ServerMain.res.mjs を実行するか、1 プロセスにまとめたい場合は concurrently と組み合わせる)

build

vp build — 本番ビルドを dist/ に出力

preview

vp preview — 本番ビルドをローカルで配信

test

vp test — Vitest スイートを実行

test:coverage

vp test --coverage — Vitest + V8 カバレッジ

check

vp check — Oxlint + Oxfmt + 型検査を一括実行

db:generate

drizzle-kit generate — Schema.res からマイグレーション SQL を生成

db:migrate

drizzle-kit migrate — 未適用のマイグレーションを適用

res:dev

rescript -w — 保存時に再コンパイル

res:build

rescript — 一回限りのコンパイル

res:clean

rescript clean

新しいページの追加

  1. src/client/Pages/<Name>.res に新しいファイルを置きます:

    @react.component
    let make = (~title) => <MainLayout> <h1> {React.string(title)} </h1> </MainLayout>
    
  2. 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 にフォールバックできます:

  1. vite.config.mjs の vite-plus を vite に置換 (import { defineConfig } from "vite")。

  2. package.json の vp ... スクリプトを vite / vitest run に直接置換。

  3. Vite+ がカバーしていた領域を埋めるため、vitest、eslint、prettier (とそれらのプラグイン) を devDependencies として追加。

Hono、Inertia、Drizzle は Vite+ に依存していないため、そのまま使えます。

2日目以降のレシピ

プロジェクトを開いた後の 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/react v3 は react ^19 を peer dependency として宣言しています。@rescript/react は React 19 で動作します。

  • 生成成果物は gitignore 済み: dist/、data/、drizzle/、.vite/、.env。