Inertia responses are how a Suprnova handler ships state to a Svelte / React /
Vue page component. Every handler that renders an Inertia page returns one,
built either through the inertia_response!
macro (for typed, compile-time-checked eager props) or the
InertiaResponse builder (for everything
else - lazy props, deferred props, merge, once, scroll, flash). This
chapter covers the response surface end-to-end: the macro, the builder, the
v3 protocol features (partial reloads, history encryption, version
detection), shared data via App::inertia_share*, and the flash bag carried
across redirects.
If you haven't picked a frontend yet, Frontend Overview and Page Components come first; this chapter assumes the SPA bridge is wired and focuses on what your handler returns.
The inertia_response! macro
The macro is the shortest path from a handler to a typed eager page. It takes the current request, a component name, and a props expression:
use ;
pub async
Three things to know:
- The leading
&reqis required. The macro readsX-Inertiaheaders, the URL, and the partial-reload filtering headers off the request, so it needs the request value (or a reference). Without it, partial reloads would silently break. - Component existence is checked at compile time. The macro looks for
frontend/src/pages/<Component>.{svelte,tsx,jsx,vue}; if no file matches, the build fails with a "did you mean…?" suggestion sourced from the actual filenames on disk. Nested paths work the same way -inertia_response!(&req, "Admin/Dashboard", …)resolvesfrontend/src/pages/Admin/Dashboard.svelte(or your frontend's extension). - The macro expands to an
awaitedResult. Your handler must returnResponse(which isResult<HttpResponse, HttpResponse>) or another type that absorbsFrameworkErrorthrough?/From. Failures during prop serialization or response building are returned asErr, not panics.
JSON-style props
For prototyping and tiny pages you can skip the typed struct:
inertia_response!
The macro still validates the component file. The trade-off is that you
lose the typed-prop chain - no #[derive(InertiaProps)], no automatic
TypeScript generation, no compile-time check that the frontend's
expected shape matches.
Optional config override
The macro accepts an optional trailing InertiaConfig for per-response
overrides (different SSR settings, a custom default title for one page):
let cfg = new.default_title;
inertia_response!
Most apps register a single config at boot via Inertia::install
and never touch this argument.
#[derive(InertiaProps)]
InertiaProps emits a Serialize impl whose key names match your field
names. It exists so the typed-props path stays terse and so the
TypeScript generator (suprnova generate-types) has a marker to find:
use InertiaProps;
Nested types compose normally - fields can be Vec<T>, Option<T>,
nested structs, anything Serialize-able. The nested types themselves
don't have to derive InertiaProps; they just need Serialize. Use
#[derive(InertiaProps)] on the top-level props struct and you get
the automatic TypeScript surface (see TypeScript Types)
for the whole tree.
The InertiaResponse builder
The macro covers eager typed props. Anything else - lazy, optional, deferred, mergeable, cached-on-client, flash, history-encryption overrides - uses the builder directly:
use ;
pub async
| Method | Purpose | Maps to Laravel |
|---|---|---|
.with(k, v) |
Eager prop, honours partial-reload filtering | typed prop |
.always(k, v) |
Eager prop, ignores partial-reload filters | Inertia::always(…) |
.lazy(k, ‖) |
Resolver runs only when prop will be sent | fn () => … closure |
.optional(k, ‖) |
Never on initial visit; must be requested explicitly | Inertia::optional(…) |
.defer(k, ‖) / .defer_with(...) |
Initial-visit-skipped; follow-up XHR triggers resolution | Inertia::defer(…) |
.merge / .merge_prepend / .deep_merge / .merge_with |
Combine with existing client state on partial reloads | Inertia::merge / deepMerge |
.once(k, ‖) / .once_with(…) |
Client caches across navigations | Inertia::once(…) |
.scroll / .scroll_with / .paginate (via Inertia::paginate) |
Infinite-scroll pagination | Inertia::scroll(…) |
.flash(k, v) |
One-shot value under page.flash (not props) |
session()->flash(…) |
.title(…) |
Default <title> for the HTML shell |
Inertia::render(…)->title(…) |
.encrypt_history(bool) |
Per-response history encryption | Inertia::encryptHistory(…) |
.clear_history() |
Force history key rotation | Inertia::clearHistory() |
.preserve_fragment(bool) |
Keep #fragment after Inertia visit |
Inertia::preserveFragment() |
Eager builder methods have try_* siblings (try_with, try_always,
try_merge_with, try_scroll, try_flash) that return
Result<Self, FrameworkError> when a value's Serialize impl might
fail at runtime - the infallible methods convert the panic into a 500
via the panic boundary, so reach for try_* when
you'd rather handle the failure explicitly.
Merge strategies and infinite scroll
.merge (append), .merge_prepend, and .deep_merge cover the common
"load more" cases. To diff-merge - update rows the client already holds
instead of duplicating them - reach for .merge_with with an explicit
MergeStrategy carrying a match_on key:
use ;
new
.merge_with
match_on names the field the client dedupes on (emitted to the page
object as matchPropsOn), so a refetch that overlaps the current window
replaces matching rows in place rather than appending copies. Prepend
and Deep take the same match_on.
Infinite scroll is the same machinery with pagination metadata attached.
.scroll / .scroll_with - or .paginate, which adapts a
LengthAwarePaginator or CursorPaginator directly - emit scrollProps
next to the data, and the client's <InfiniteScroll> component drives the
next/previous fetches:
// `posts` is a CursorPaginator from the query builder.
new.paginate
The framework reads the merge direction from the
X-Inertia-Infinite-Scroll-Merge-Intent request header the client sends
(append when scrolling down, prepend when scrolling up). On a fresh
visit - no intent header - scrollProps["posts"].reset is true, so the
client clears its accumulator before rendering the first window.
Partial reloads
The Inertia 3 client can request a subset of a page's props (or a superset by including an Optional or Defer key). The protocol uses three request headers:
| Header | Meaning |
|---|---|
X-Inertia-Partial-Component |
The component being partial-reloaded - must match the response's component for filtering to apply. |
X-Inertia-Partial-Data |
Whitelist: comma-separated prop keys to include. |
X-Inertia-Partial-Except |
Blacklist: comma-separated prop keys to exclude. Wins over Partial-Data on key collision. |
Filtering rules:
Eager,Lazy,Merge,Once,Scrollprops follow whitelist / blacklist semantics.Alwaysprops are sent regardless.OptionalandDeferprops are never on a standard visit and only appear on a matching partial reload that explicitly lists the key.
The handler doesn't have to do anything special - register every prop through the builder, and the framework consults the headers when serializing the page object.
Shared data via App::inertia_share*
Some props are the same on every Inertia page - auth state, the CSRF token, the current locale, app-wide flags. Register them once at bootstrap and they merge into every response:
use App;
use Arc;
For per-request shared data (the authenticated user, request-scoped
flags), implement InertiaSharedData and
register the singleton - the framework calls share(&req) on every
Inertia response and merges the result.
Precedence on key collision
When the same key appears in more than one layer, later writes win:
- Static registry (
App::inertia_share/App::inertia_share_lazy) - Per-request trait provider (
InertiaSharedData::share) - Per-response builder methods (
.with,.lazy, etc.)
This lets a handler override a globally-shared default for one page without having to unregister anything.
Per-request shared data
The trait runs once per Inertia response with access to the request.
Implementations need async_trait (re-exported as suprnova::__async_trait)
and IndexMap (re-exported as suprnova::indexmap):
use ;
use Arc;
;
// In bootstrap:
register_inertia_shared;
Flash and redirects
Flash data is one-shot state that should appear on the next render and
disappear after - toast messages, "just created" IDs, validation summaries.
Suprnova surfaces it under page.flash on every Inertia response. There
are three writers:
// 1. Push into the current request's flash bag.
flash;
// 2. Attach to a specific response (same effect on this response only).
new.flash
// 3. Carry across a redirect via the Redirect facade.
use Redirect;
to.with
The Redirect::with(key, value) form is the cross-handler path: the
value lands in the session under _flash.new.*, the next request's
SessionMiddleware ages it into _flash.old.*, and the
destination's InertiaResponse surfaces it under page.flash.
Same-request flash (the task-local bag) wins over inherited session flash on key collision, so a destination handler can override an inbound value just by re-flashing the key.
Internal session keys (anything prefixed _) are filtered out of
page.flash - _old_input for form repopulation and _inertia.*
protocol flags don't leak to the client.
Redirect helpers
Redirect is the full Laravel surface:
to // 302 to a path
route.with // named route, route params
back // session-recorded previous URL
refresh // same URL, fresh GET
guest // stashes intended URL
intended // pops the stashed URL
signed_route? // signed URL
to.preserve_fragment // keep #frag across visit
All Redirect variants accept .with(k, v), .with_input(map),
.with_errors(map), .with_errors_bag(name, map), .cookie(c),
.header(k, v), .permanent(), .status(303), etc. The full chain
mirrors Laravel's RedirectResponse.
For non-GET Inertia visits, the framework auto-converts the response to
303 See Other when Inertia303Middleware
is installed, so the browser issues a clean follow-up GET instead of
re-submitting the original PUT/PATCH/DELETE to the redirect target.
Version detection
Inertia versions the asset manifest so a long-lived client doesn't try
to mount a page from yesterday's bundle against today's server. When
the client's X-Inertia-Version header doesn't match the server's
configured version, InertiaVersionMiddleware
responds with 409 Conflict and an X-Inertia-Location header naming
the new URL - the Inertia client picks that up and does a full page
reload, picking up the new bundle.
You set the version through InertiaConfig:
use InertiaConfig;
// Static - most apps. Bake in a build-time identifier.
let cfg = new.version;
// Dynamic - read a manifest hash, container deployment ID, anything.
// The closure runs on every version check; cache inside if it isn't cheap.
let cfg = new.version_with;
For async or fallible version resolution (e.g. read a manifest hash
from S3), do the read once at boot and pass the cached String to
.version(...).
Bootstrap: Inertia::install
Most apps install the two protocol middlewares in one call:
use ;
Inertia::install returns Result and, in order:
- Fails closed if
cfgresolves to production mode (development == false- the default wheneverAPP_ENV=production) but no Vite manifest can be loaded fromcfg.manifest_path. This is the CFG-01 guard: a production boot with an unbuilt frontend errors loudly instead of silently falling back to a legacy hardcoded asset path. - Registers
InertiaVersionMiddleware- emits the409+X-Inertia-Locationwhen client and server disagree on the asset version. - Registers
Inertia303Middleware- upgrades302to303on non-GET Inertia redirects.
Skip the call only if you genuinely don't want one of these middlewares (rare; both close real failure modes - silent stale-bundle and form-replay-on-redirect).
Server-driven <head> elements
Inertia 3.5 added a client option for letting the server decide what goes in
<head> - useful when meta tags depend on the record you just loaded, and you
don't want the title and OG tags to live in two places.
This needs no framework support. The client reads the elements from an ordinary prop, so any handler can supply them:
async
Opt in on the client:
Each string is an HTML element. The client stamps a data-inertia attribute on
anything that lacks one so it can diff head elements across navigations; supply
your own data-inertia="og-title" when you want stable identity rather than
positional matching.
Escape anything interpolated from user data - these strings are injected as HTML, so the usual rules apply.
SSR
Suprnova talks to an out-of-process SSR worker - typically the
@inertiajs/{svelte,react,vue}/server createServer() bundle run
under Node / Bun / Deno - over HTTP loopback. Enable it on the
config:
new
.ssr // worker URL
.ssr_timeout
.ssr_exclude
.ssr_max_response_bytes
SSR is off by default. When enabled, the framework posts the page
object to <url>/render and inlines { head, body } in the HTML
shell. On worker error or timeout the response falls back to CSR
(an empty <div id="app"> the client hydrates) and the
on_ssr_error(...) hook fires; flip ssr_throw_on_error(true) in CI
to make those failures hard 500s instead.
Boot the worker separately - suprnova ssr:start is the standard
runner once your project ships an SSR entry.
Configuration
Inertia behaviour is configured programmatically via InertiaConfig.
The one env var the framework reads directly is SUPRNOVA_FRONTEND
(svelte / react / vue), which selects the default entry-point
filename and page-component extensions. Everything else is
builder-shaped:
use ;
let cfg = new
.frontend // overrides SUPRNOVA_FRONTEND
.vite_dev_server
.entry_point
.version
.default_title
.manifest_path
.assets_base_url
.max_concurrent_resolvers // cap lazy-prop fan-out
.production; // false → loads from Vite dev server
Frontend-specific defaults:
| Frontend | Default entry point | Page extensions |
|---|---|---|
| Svelte (default) | src/main.ts |
.svelte |
| React | src/main.tsx |
.tsx, .jsx |
| Vue | src/main.ts |
.vue |
The Vite manifest at manifest_path is loaded lazily on first request
and cached for the process lifetime. When it's missing, production
asset tags fall back to a hardcoded legacy path and a tracing::warn!
fires so the gap surfaces in logs.
Why Suprnova diverges
Laravel's Inertia adapter has a single global "shared data"
registry plus a per-request Inertia::share($k, $v) call. PHP's
request-per-process model makes this safe: a fresh process per request
means no leakage between concurrent visitors.
Rust's process model is the opposite - one process serves many
concurrent requests across many threads. So the registry lives on
the container (task-local → thread-local → global),
not in process-global statics. App::inertia_share* writes to the
active container's InertiaRegistry, which gives tests using
TestContainer::fake() clean isolation without having to unregister
anything. Same surface as Laravel; different machinery underneath
because the runtime is different.
Two other Rust-shaped choices worth flagging:
- Lazy-prop resolvers run concurrently, capped by
max_concurrent_resolvers(default 16). A page with twelve lazy props issues twelve parallel queries inside one Tokio task - that's what we built the framework on top of Tokio for. Tune the cap if a page has many lazy props each hitting an external service. - The compile-time component check isn't a Laravel feature at all,
because PHP can't see your frontend files at compile time. Suprnova
does, so a typo in
inertia_response!("Dashbaord", …)fails the build with a "did you mean Dashboard?" suggestion instead of surfacing as a runtime "component not found" later.
Next
- Page Components - how the frontend resolves a component name to a Svelte / React / Vue module
- TypeScript Types -
suprnova generate-typesemits TS definitions from your#[derive(InertiaProps)]structs - Data Objects -
#[derive(Data)]for DTOs with per-field include/allowlist gating that composes with partial reloads - Error Model - how
Response, the panic boundary, andFrameworkErrorthread through Inertia responses - Container - the lookup model behind
App::inertia_share*andInertiaSharedData
