Hono + Inertia (React)¶
Node.js / Bun Inertia.js Server-driven Vite+ alpha
A server-driven SPA in which Hono routes call c.render(component, props) through the @hono/inertia middleware, and @inertiajs/react v3 mounts the matching React page on the client. There is no separate REST/GraphQL layer — the controller hands props straight to the page component.
The template uses Vite+ (vite-plus), VoidZero’s unified toolchain that collapses Vite, Vitest, Oxlint, Oxfmt, and Rolldown behind a single vp CLI. SSR is on by default: src/Ssr.res server-renders each page through react-dom/server’s renderToString, the rendered HTML is embedded in <div id="app">…</div>, and the browser entry hydrates with hydrateRoot. Subsequent Inertia visits (X-Inertia: true) return JSON only and bypass SSR.
What You Get¶
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
Wizard Options¶
Option |
Effect |
|---|---|
Project name |
Becomes the npm |
Package manager |
npm / pnpm / yarn / bun. Affects |
Validation library |
|
Key Dependencies¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler |
|
|
Standard library |
|
|
Runtime stubs the compiled |
|
|
React JSX bindings |
|
|
React runtime (peer of |
|
|
HTTP router |
|
|
Node adapter for Hono |
|
|
Inertia middleware that overrides |
|
|
Inertia client + |
|
|
SQLite driver (also speaks Turso |
|
|
Type-safe SQL builder |
|
|
Form input validation (chosen in the wizard) |
|
|
Underlying bundler — kept as a direct dep for the Vite+ → Vite fallback |
|
|
Vite+ unified CLI ( |
|
|
Vite+ runtime helpers |
|
|
React fast-refresh + JSX support |
|
|
Migration generator/runner |
|
Note: vitest, eslint, and prettier are deliberately omitted — Vite+ ships them under vp test / vp check already.
Key Files¶
src/Server.res¶
The middleware order is the linchpin: inertia() must be registered before any route that calls c->HonoInertia.render(...).
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 never calls serve() at module top level — the work lives in start() and is invoked from ServerMain.res. Tests can therefore import("../Server.res.mjs") without binding port 3000.
src/Routes.res¶
Each Inertia route renders a named React page and hands typed props:
app->Hono.get("/", async ctx => {
await ctx->HonoInertia.render(
"Home",
{"title": "Home", "message": "Hono renders React pages directly through Inertia."},
)
})
Inertia sniffs the X-Inertia header on the request: present → JSON page envelope; absent → the HTML host page (so deep links and address-bar typing work).
src/HonoInertia.res¶
@module("@hono/inertia")
external inertia: unit => Hono.middleware = "inertia"
@send
external render: (Hono.context, string, 'props) => promise<'response> = "render"
The middleware overrides Hono’s existing c.render, so we model render as a @send external against the same context type the rest of the template already uses.
src/InertiaBindings.res¶
Minimal bindings for @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 calls usePage() to read page.url; pages call <InertiaBindings.Link href="/about"> instead of plain <a> tags so Inertia intercepts the click.
src/client/pages.js — the JS shim¶
ReScript 12’s Js.import resolves the import path statically — import("./Pages/" ++ name + ".res.mjs") is rejected by the compiler. import.meta.glob is a Vite-only extension that builds a lazy-loader table at build time, so we keep the resolver in plain 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 reads .default off whatever resolve returns; ReScript-compiled modules expose their React component as make, so the shim re-wraps it under 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}
}
Pages render through Inertia’s React <App> so usePage() (used by MainLayout) finds its provider context. resolveComponent runs synchronously, which keeps renderToString synchronous and rootView itself synchronous. Adding a new page requires updating this switch alongside client/Pages/ and Routes.res — the explicit registry catches missing pages at compile time rather than as runtime 500s.
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,
},
},
},
});
The proxy forwards Inertia GET/POST requests to the Hono server during dev; static assets and HMR continue to be served by Vite+ directly. In production the same Hono server serves dist/ and the React bundle is just a static asset.
npm Scripts¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Adding a New Page¶
Drop a new file under
src/client/Pages/<Name>.res:@react.component let make = (~title) => <MainLayout> <h1> {React.string(title)} </h1> </MainLayout>
Register a route in
src/Routes.res:app->Hono.get("/the/path", async ctx => { await ctx->HonoInertia.render("<Name>", {"title": "Hello"}) })
pages.js discovers the new file automatically through import.meta.glob — no central registry to edit.
Caveats — Vite+ alpha¶
Vite+ is currently shipping in alpha (0.1.x at the time of writing). The CLI and config surface are still in flux:
The
test/lint/fmtconfiguration keys are not yet documented invite.config.mjs. Defaults work; configuration may move.API changes between 0.1.x patch releases are possible.
If a Vite+ release breaks this template, you can fall back to plain Vite by:
Replacing
vite-pluswithviteinvite.config.mjs(import { defineConfig } from "vite").Replacing the
vp ...scripts inpackage.jsonwithvite/vitest rundirectly.Adding
vitest,eslint,prettier(and their plugins) as devDependencies to fill the gaps Vite+ used to cover.
Hono, Inertia, and Drizzle do not depend on Vite+ and stay untouched.
Day-Two Recipes¶
Add a Hono Endpoint — adding a REST route alongside Inertia pages (e.g. webhook intake, health probes)
Set Up Drizzle — Drizzle schema, migrations, and Turso swap
For ReScript-side editor workflows once the project is open, see the Feature Overview.
Notes¶
CSR only. The template renders pages on the client; server-side rendering is planned as a follow-up. If you need first-paint SEO today, render a static skeleton in
index.htmlor pre-render specific routes withvite build --ssr.Server.resnever callsserve()at module top level — that work lives instart()and is invoked fromServerMain.res. Tests can thereforeimport("../Server.res.mjs")without binding port 3000.Inertia middleware order matters.
HonoInertia.inertia()must be registered before any route that callsc->HonoInertia.render(...). The shippedServer.resenforces this with a comment.The
pages.jsshim is intentional. It is the smallest piece of JS we can ship — five lines aroundimport.meta.glob— and lets the rest of the template stay pure ReScript.Defaults to a local SQLite file.
DATABASE_URL=file:./data/app.db. Swap for alibsql://URL (Turso) without touching code.React 19 is required.
@inertiajs/reactv3 declaresreact ^19as a peer dependency.@rescript/reactworks with React 19.Generated artifacts are gitignored:
dist/,data/,drizzle/,.vite/,.env.