Skip to main content
zerotal

Rendering pages

Every Inertia response goes through one helper: inertia(). This section covers how it resolves components, what it returns for each request type, controller-less page routes, and how redirects behave.

The inertia helper

function inertia(component: PageName, props?: RenderProps): Promise<void>;

Call inertia() from any controller action. It reads the active request from RequestContext (via AsyncLocalStorage), so there is no context argument — and it sets ctx.response as a side effect. It is async (it resolves lazy/deferred props), so it returns Promise<void> — always return inertia(...) (or await it):

// app/controllers/PostController.ts
import type { HttpContext } from "zerotal";
import { inertia } from "@zerotal/inertia";
import { Post } from "../models/Post.ts";

export class PostController {
  async index(ctx: HttpContext): Promise<void> {
    const posts = await Post.query()
      .withScopes((s) => s.published())
      .with("author")
      .orderBy("published_at", "desc")
      .paginate(10, Number(ctx.query("page", "1")));

    return inertia("Posts/Index", { posts });
  }

  async show(ctx: HttpContext): Promise<void> {
    const post = await Post.query()
      .where("slug", ctx.params["slug"]!)
      .with("author")
      .with("comments")
      .firstOrFail();

    return inertia("Posts/Show", { post });
  }
}

return inertia(...) is idiomatic: the helper returns Promise<void>, so returning it ends the action.

Warninginertia() takes (component, props)not inertia(ctx, component, props). The request is resolved from context automatically.

Controlling which props are sent, and when

Props can be more than plain values. Wrap them to make them lazy, optional, deferred, or mergeable — the foundation for partial reloads, "load more" lists, and deferred content:

// in a controller
import { inertia, optional, defer, merge } from "@zerotal/inertia";

return inertia("Users/Index", {
  users: () => User.all(), // lazy — only evaluated when sent
  roles: optional(() => Role.all()), // only on a partial reload that asks for it
  stats: defer(() => computeStats()), // loaded after first paint
  feed: merge(() => Post.paginate(15, page)), // appended on "load more"
});

A unified Inertia facade exposes the full protocol API (Inertia.render, Inertia.optional, Inertia.defer, Inertia.merge, Inertia.share, Inertia.location, …). See Data Props for the full v3 feature set.

Component resolution

The component string maps to a file under your pages directory (resources/js/pages/ by default, configurable via pagesDir), with the framework extension appended (.tsx for React, .vue for Vue):

inertia(...) callComponent file
inertia("Dashboard")resources/js/pages/Dashboard.tsx
inertia("Posts/Index")resources/js/pages/Posts/Index.tsx
inertia("Admin/Users/Edit")resources/js/pages/Admin/Users/Edit.tsx

Component names are validated — a name containing .. or a leading / is rejected to prevent path traversal.

The name is checked at compile time

inertia("Posts/Shwo", …) does not compile: the name has to be a page in the generated registry (resources/js/pages.generated.ts, rebuilt by bun zt inertia:build and by zt dev). A renamed or misspelled page was a runtime 500 before — the kind that reaches production because the route it lives on is the one nobody clicked.

For a name that genuinely isn't known until runtime, inertia.dynamic(name, props) takes any string and skips the check.

The props are checked too — see Typed props.

Props serialization

Props are JSON-serialized into the page object. Pass plain data, not live ORM models with unloaded relations — eager-load what the page needs (.with("author")) or map to a plain shape. Shared props (auth.user) are already reduced to scalars for you; see Shared Props.

First load vs. navigation

inertia() branches on the X-Inertia request header:

RequestResponse
First load (no X-Inertia header)Full HTML document with the page object JSON embedded
XHR navigation (X-Inertia: true)JSON page object only (Content-Type: application/json)

Both responses carry Vary: X-Inertia so browsers and CDNs cache the HTML and JSON variants separately. The page object always includes the current url and asset version.

Controller-less routes

For pages that need no controller logic (marketing pages, static dashboards), render straight from the route with the Router.inertia() macro (added by the package):

// routes/web.ts
import { Router } from "zerotal";

Router.inertia("/about", "About/Index"); // no props
Router.inertia("/home", "Home/Index", { greeting: "Hello" }); // static props
Router.inertia("/admin", "Admin/Dashboard", [AuthMiddleware]); // middleware shorthand

The third argument is polymorphic: pass a props object, or pass a middleware array directly as a shorthand. To use both, pass props third and middleware fourth:

// routes/web.ts
Router.inertia("/admin", "Admin/Dashboard", { title: "Admin" }, [AuthMiddleware]);

Building URLs with route()

A hard-coded href="/posts/hello" is a string nothing checks. Rename the route and every link to it keeps compiling and starts 404ing — a bug that surfaces when someone clicks, not when someone builds.

Name the route instead, and let the URL be derived:

import { Link } from "@inertiajs/react";

<Link href={route("posts.show", { slug: post.slug })}>{post.title}</Link>
<Link href={route("posts.index", {}, { page: 2 })}>Next</Link>

No import for routedefineRoutes() installs it globally, and the names are checked against the same registry your controllers use, so route("posts.shwo") fails the build. Routing owns the mechanics: the generated table, wiring your entry point, typing, and route.dynamic() for a name only known at runtime.

Forms submit to a name too

A form's action is the same kind of string as a link's href, and gets the same treatment. useForm() and router both take a URL, so hand them one that was built from the route name:

import { useForm, router } from "@inertiajs/react";

export default function Edit({ post }: Props) {
  const form = useForm({ title: post.title, body: post.body });

  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    form.put(route("posts.update", { slug: post.slug }));
  };

  const destroy = () => {
    router.delete(route("posts.destroy", { slug: post.slug }));
  };

  return (
    <form onSubmit={submit}>
      <input value={form.data.title} onChange={(e) => form.setData("title", e.target.value)} />
      {form.errors.title && <span>{form.errors.title}</span>}
      <button disabled={form.processing}>Save</button>
      <button type="button" onClick={destroy}>
        Delete
      </button>
    </form>
  );
}

The names follow the same convention the router generates: a POST is posts.store, PUT/PATCH is posts.update, DELETE is posts.destroy. So the name in the component and the route the controller is mounted on cannot drift apart silently — change the URL and both ends move together.

This matters more for a form than for a link. A broken link 404s where someone can see it; a form posting to a stale URL fails after the user has filled it in, and the data goes with it.

Build the URL the same way for Precognition, so live validation and the real submit cannot end up aimed at different routes — the failure there is a form that validates clean and then rejects on save.

One thing Inertia adds: define the routes in both entries

An Inertia page renders twice — once in the SSR process, once in the browser — so a component calling route() runs in both. A table defined in only one of them throws in the other: miss the SSR entry and POST /__ssr answers 500 with [Inertia] SSR render failed in the log, for a page the browser then renders perfectly well.

Call defineRoutes(ROUTES) in your browser entry and in your SSR entry. Same static import, same table.

Redirects

After a non-GET action (a form POST/PUT/DELETE), redirect as usual — return a 302 and InertiaMiddleware upgrades it to a 303 so the browser issues a GET on the target instead of replaying the form:

// in a controller
async store(ctx: HttpContext): Promise<void> {
  const post = await Post.create(await ctx.body());
  ctx.flash("success", "Post created.");
  return ctx.redirect(`/posts/${post.slug}`); // 302 → 303, then renders Posts/Show
}

Validation failures redirect back with errors in the session, which surface as the errors shared prop on the re-rendered page — again, see Shared Props.

External redirects — Inertia.location

To send the browser to an external URL (or force a full-page visit), use Inertia.location(url). On an Inertia request it returns a 409 + X-Inertia-Location so the client does a window.location visit; on a normal request it's a plain 302:

// in a controller
import { Inertia } from "@zerotal/inertia";

return Inertia.location("https://billing.stripe.com/session/abc");

Redirects to a target with a URL fragment (/page#section) are automatically converted to a 409 + X-Inertia-Redirect on Inertia requests so the fragment is preserved across the visit. See External & fragment redirects.

Next steps