Inertia
Inertia.js lets you build a single-page app using server-side routing and controllers — no separate API layer, no client-side router. Your controllers return Inertia page responses; Inertia renders the matching React (or Vue) component client-side on navigation, and returns a full HTML document on the first load.
Zerotal's @zerotal/inertia package is a native, Bun-powered Inertia server adapter with full
Inertia v3 protocol support: controller-less page routes, automatic shared props, asset
versioning, the complete data-props layer (partial reloads, optional/defer/merge/once props,
history encryption, precognition), a make:page generator, a Bun.build-based bundler, and optional
streaming SSR — all wired by a single provider. The stock @inertiajs/react / @inertiajs/vue3
clients work against it unchanged.
How it works
- First load — a
GETrequest hits a controller, which callsinertia(). The server renders the full HTML document with the page object (component name + props) embedded as JSON. - Client boot — the Inertia client reads that page object and mounts the named component.
- Navigation — subsequent links/visits send an
X-Inertia: truerequest. The same controller runs, butinertia()returns only the JSON page object — the client swaps the page component without a full reload.
The server stays the source of truth for routing and data; the client is just a thin renderer that morphs between pages.
Getting Started
The framework adapter (React or Vue) is a peer dependency you install in your app — the server package works against whichever you choose:
# in your project root
bun add @inertiajs/react react react-dom # React
# or
bun add @inertiajs/vue3 vue # Vue
@zerotal/inertia itself ships with the framework. If you're adding it to an
existing app, bun add @zerotal/inertia.
Register the provider
Add InertiaProvider to the providers array in bootstrap/providers.ts:
// bootstrap/providers.ts
import { InertiaProvider } from "@zerotal/inertia";
const providers = [
// …your other providers
InertiaProvider,
];
export default providers;
That's the only wiring you need. Registering the provider switches on the following (in lifecycle order):
onRegister— registers theRouter.inertia()route macro so it's available before routes load.onBooting— auto-registersInertiaMiddlewareviaapp.useOnce(), loads the HTML template and asset version into memory, resolves the pages directory, applies the history-encryption default, and (whenssr: true) registersPOST /__ssr.onBooted— registers Inertia's dev build routine (alongside any other view layer's, so both keep rebuilding) and lazily registers themake:pageandinertia:buildCLI commands when running in console mode.
You do not add InertiaMiddleware to .use() manually (see Middleware & Versioning).
Configuration
Create config/inertia.ts with the InertiaConfig() helper (or satisfies InertiaConfigShape). Every field has a default, so an empty InertiaConfig({}) is valid:
// config/inertia.ts
import { InertiaConfig } from "@zerotal/inertia";
import { env } from "zerotal";
export default InertiaConfig({
htmlTemplate: "./resources/app.html", // root template — must contain <!-- @inertia -->
version: env("ASSET_VERSION", "1"), // cache-bust string; bump on each deploy
assetsUrl: "/", // public URL prefix for built assets
pagesDir: "resources/js/pages", // where page components live
ssr: false, // set true to enable POST /__ssr (see SSR)
});
| Field | Required | Default | Description |
|---|---|---|---|
htmlTemplate | no | "./resources/app.html" | Path to the root HTML template (must contain <!-- @inertia -->); falls back to a built-in default. |
version | no | "1" | Asset version string embedded in every page object; bump on each deploy. |
assetsUrl | no | "/" | Public URL prefix for built assets. |
pagesDir | no | "resources/js/pages" | Directory (relative to the project root) where Inertia page components live. |
ssr | no | false | Register POST /__ssr for endpoint SSR (requires a server renderer). |
encryptHistory | no | false | Encrypt browser history state globally; per-page overrides via Inertia.encryptHistory(). |
HTML template
The template is loaded once at boot; <!-- @inertia --> is where the page
object and root <div> are injected on every response:
<!-- resources/app.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My App</title>
<script type="module" src="/assets/app.js" defer></script>
</head>
<body>
<!-- @inertia -->
</body>
</html>
Note — If
htmlTemplateis missing,InertiaProviderfalls back to a built-in default template in development so the app still boots — but in production a missing template throws. Provide your own for real projects.
Your first page
A controller action calls inertia(component, props):
// app/controllers/DashboardController.ts
import type { HttpContext } from "zerotal";
import { inertia } from "@zerotal/inertia";
import { Post } from "../models/Post.ts";
export class DashboardController {
async index(ctx: HttpContext): Promise<void> {
const posts = await Post.query().latest().limit(5).get();
return inertia("Dashboard", { posts });
}
}
inertia() reads the current request from context — it takes no context
argument — and sets the response as a side effect, so the action returns
Promise<void>. The component name ("Dashboard") maps to
resources/js/pages/Dashboard.tsx.
// resources/js/pages/Dashboard.tsx
import { Link } from "@inertiajs/react";
interface Props {
posts: { id: number; slug: string; title: string }[];
auth: { user: { name: string } | null }; // from shared props
}
export default function Dashboard({ posts, auth }: Props) {
return (
<main>
<h1>Dashboard</h1>
{auth.user && <p>Welcome back, {auth.user.name}</p>}
{posts.map((post) => (
<Link key={post.id} href={route("posts.show", { slug: post.slug })}>
{post.title}
</Link>
))}
</main>
);
}
Note auth is available without the controller passing it — see
Shared Props. Generate new pages with
make:page and bundle them with
inertia:build.
route("posts.show", { slug }) builds the URL from the route's name rather than
hard-coding the path, so renaming a route updates every link to it and a typo fails
the build. Prefer it over a literal href anywhere you link — see
Building URLs.
Testing
Set your suite up once as described in Testing. An Inertia route serves two different things depending on one header, so a test has to say which one it wants.
Send X-Inertia: true to get the page object instead of the HTML shell. This
is the assertion you want in almost every route test — it checks the component
and its props without parsing markup:
// tests/http/dashboard.test.ts
import { test } from "bun:test";
import { createApp } from "../helpers.ts";
test("the dashboard renders with its stats", async () => {
const app = await createApp();
const res = await app.actingAs(user).get("/dashboard", { "X-Inertia": "true" });
res.assertJsonPath("component", "Dashboard");
res.assertJsonPath("props.stats.orders", 12);
await app.close();
});
Without that header you get the full HTML document with the page object
embedded in a data-page attribute — right for asserting the first paint, wrong
for asserting props:
// tests/http/dashboard.test.ts
const res = await app.actingAs(user).get("/dashboard");
res.assertSee('<div id="app"');
res.assertHeader("Vary", "X-Inertia"); // the response varies by that header
Test partial reloads by the props they omit. A partial reload that quietly returns everything is a performance bug no page-level assertion catches:
// tests/http/dashboard.test.ts
const res = await app.actingAs(user).get("/dashboard", {
"X-Inertia": "true",
"X-Inertia-Partial-Data": "stats",
"X-Inertia-Partial-Component": "Dashboard",
});
res.assertJsonPath("props.stats.orders", 12);
const page = res.json<{ props: Record<string, unknown> }>();
expect(page.props.notifications).toBeUndefined(); // excluded, as asked
A version mismatch is a 409, not an error. It tells the client to reload so
it picks up new assets — worth a test if you set ASSET_VERSION on deploy:
// tests/http/dashboard.test.ts
const res = await app.get("/dashboard", { "X-Inertia": "true", "X-Inertia-Version": "stale" });
res.assertStatus(409);
res.assertHeader("X-Inertia-Location");
Note — For the client half — a component rendering, a form submitting, a deferred prop arriving — use Browser Tests. These assertions stop at the boundary your server owns.
The rest of the guide
| Page | What it covers |
|---|---|
| Rendering Pages | Return a page from a controller, choose a component, and control the response. |
| Props | Pass data to a page — eager, lazy, deferred, and merged props, plus the shared props every page receives. |
| Middleware & Versioning | The Inertia middleware, asset versioning, and forcing a full reload after a deploy. |
| Server-Side Rendering | Render the first paint on the server, and what changes when you do. |
| DevTools | Record each request's component, props, and timing for the Inertia DevTools extension. |
| CLI & Build | The page registry, the bundler pipeline, and building for production. |
| Reference | Every helper, prop type, and config key in one table. |
Next steps
- Controllers — where most
inertia()calls live. - Routing — declare routes and the
Router.inertia()macro targets. - Middleware — how
InertiaMiddlewareslots into the pipeline. - Validator — the validation that feeds the
errorsshared prop and precognition. - Providers — the lifecycle hooks
InertiaProviderbuilds on. - Pagination — the paginators
merge()andscroll()consume.