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.
For a page with no logic at all - about, terms, privacy - skip the handler entirely and declare the route:
use Router;
use json;
let router = new.inertia;
See Routing. The component there is a runtime string, so it doesn't get this macro's compile-time existence check - that's the trade for not writing the handler.
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 - the installed config is already what
every response starts from. Pass one here only to override the installed
config for a single page.
#[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(…) |
.always_with(k, ‖) |
Async resolver, ignores partial-reload filters | Inertia::always(fn () => …) |
.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 / .scroll_wrapped / .scroll_with_wrapped / .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 on this page | 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_scroll_wrapped, 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.
.clear_history() marks the response you are building. A logout handler
redirects, and the browser discards the redirect's response - so the login
page, not the logout response, is the one that has to carry the flag.
App::clear_history() is the fix for that case - it's a free function, not
a builder method, so it isn't in the table above. It flashes a one-shot
session flag that the next Inertia page object turns into
clearHistory: true. It needs a session scope, and it survives exactly
one hop.
Call it after Auth::logout() / Auth::logout_and_invalidate(), not
before - invalidation flushes the whole session, and the flag lives in
that session, so flashing it first only gets erased by the flush:
use ;
pub async
Composing flags on one prop
The methods above each set one flag. A prop can carry several, and some
combinations are how the Inertia protocol expects real pages to work: a
deferred list that appends into what the client already rendered, a
merge prop the client caches across navigations, an optional prop with
its own cache key. Build the prop with Prop, then attach it with
.prop(key, prop):
use ;
use json;
new.prop
That prop is skipped on the first render and announced under
deferredProps. The client issues its follow-up request, the resolver
runs, and the value arrives with a mergeProps instruction, so it
appends to the list already on screen instead of replacing it.
The flags fall into five groups:
| Group | Methods | Effect |
|---|---|---|
| Visibility | .always(), .optional(), .defer() |
Mutually exclusive; the last call wins |
| Defer detail | .group(name), .rescue() |
Read only when the prop is deferred |
| Merge | .merge(), .prepend(), .deep_merge(), .match_on(fields), .merge_with_path(path) |
How the client folds the value in, and at which path |
| Client cache | .once(), .as_key(key), .until(ms), .fresh() |
Whether the client keeps the value across navigations |
| Scroll | .scroll(metadata), .scroll_wrap(key) |
Infinite-scroll scrollProps entry plus unconditional merge metadata; .scroll_wrap read only when .scroll is set |
Sources are Prop::eager(value), Prop::lazy(closure),
Prop::from_resolver(resolver) for a resolver you built yourself, and
Prop::absent() for a prop that never reaches the response - what
when_loaded! returns for an unloaded relation.
Two rules are worth knowing before you compose:
- Visibility is one setting, not three flags.
.always().optional()is an optional prop, and.optional().always()is an always prop. Neither is an error; the earlier call is erased. - Metadata follows the partial-reload lists, not the value. A prop's
mergeProps,onceProps, andscrollPropsentries are emitted whenever the key passesX-Inertia-Partial-DataandX-Inertia-Partial-Except, even on a visit where the value itself is withheld. That is what carries the merge instruction across a deferred prop's two requests. Two consequences follow:- An
.always().merge()prop outside the requested set still sends its value and does not send its merge instruction, so the client replaces rather than appends. scrollPropshas one extra condition on top of the lists: a.scroll().defer()prop announces its merge instruction on a non-partial visit but ships no cursor there, because nothing is on screen yet for a cursor to describe. Every matched partial reload gets the cursor, whether or not that request also resolves the value.deferredPropsis the one block the lists never govern. It is dropped whole on any matched partial reload, no matter what the lists say - Laravel'sresolveDeferredPropsreturns[]the moment the request is partial. A partial reload is the client working through announcements it already holds, so re-announcing the keys it left out of this round would send it back for them again. A partial reload aimed at a different component is a standard visit for every gate, announcements included.
- An
.group(name) and .rescue() are stored on any prop but only read when
the prop is deferred, so .rescue().defer() and .defer().rescue()
mean the same thing. A scroll prop takes its merge direction from the
client's X-Inertia-Infinite-Scroll-Merge-Intent header, so .merge()
and .prepend() on a scroll prop are redundant and not read.
.deep_merge() is the exception: it routes the prop into
deepMergeProps instead of mergeProps, the same way Laravel's
ScrollProp does.
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(s) the client dedupes on (emitted to the page
object as matchPropsOn) - one field or several, the same as
Prop::match_on (below) - 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.
MergeStrategy is the one-call form. Prop::merge() / .prepend() /
.deep_merge() / .match_on(field) are the same settings as separate
flags, for when the prop also needs a visibility or cache flag - see
Composing flags on one prop.
.match_on takes one field or several in one call -
.match_on(["id", "slug"]) and .match_on("id").match_on("slug") emit
the same matchPropsOn.
To merge only part of a prop's value instead of the whole thing, name
the nested field with .merge_with_path:
use ;
use json;
new.prop
mergeProps now carries "posts.data" instead of "posts", so only
props.posts.data folds into what the client already holds -
props.posts.meta is replaced outright, like any non-merge prop. Calls
accumulate, so a prop with two mergeable fields can name each
independently. Naming a path turns off root-level merging for that prop
entirely - a path-merging prop never also merges its whole value.
match_on composes with a path by including the path in the field name
("data.id", not "id"); the framework doesn't infer it for you.
.deep_merge() ignores .merge_with_path - a deep merge already
recurses into every nested field, so there's nothing a path narrows.
A merge prop's value can come from a resolver too, via .merge_lazy /
.merge_lazy_with - the resolver sibling of .merge / .merge_with:
new.merge_lazy
The resolver runs only when the merge prop will actually be sent -
skipped by partial-reload filtering and by .defer() like any other
resolver-backed prop.
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
A scroll prop always carries merge metadata, not just on a follow-up
fetch: it defaults to append, and switches to prepend only when the
client's X-Inertia-Infinite-Scroll-Merge-Intent header says so (append
when scrolling down, prepend when scrolling up). reset is independent
of that header - it's true exactly when the client named the key in
X-Inertia-Reset, the same header a regular merge prop reads. A fresh,
unfiltered visit sends neither header, so it gets reset: false and an
append instruction, matching Laravel.
.merge_with_path has no effect on a scroll prop - the scroll block that
computes its merge instruction reads Prop::scroll_wrap's single wrap
key, not .merge_with_path's accumulated path list, so
.scroll(metadata).merge_with_path("data") stores a path nothing reads.
.scroll_wrap - reached directly through .prop(...), or through the
.scroll_wrapped response shortcut below - is the nesting equivalent for
a scroll prop.
A scroll prop also honors .match_on(...), the same as any other merge
prop - reach it through .prop(...), since neither .scroll nor
.match_on has a combined response-level shortcut:
new.prop
The match field keys off wherever the prop actually merges: the bare key
when unwrapped (matchPropsOn: ["users.id"]), or key.wrap_key under
.scroll_wrap(...) (matchPropsOn: ["posts.data.id"] for a prop wrapped
under "data") - so the entry always lines up with the merge path the
client folds, instead of silently never matching.
When the prop's value is itself a wrapped structure - { data: [...], meta: {...} }, the shape a hand-built API resource typically returns -
merging the whole object would clobber meta on every fetch. Point the
merge at the array field instead with .scroll_wrapped:
new.scroll_wrapped
mergeProps then names posts.data, so the client folds new rows into
the nested array and leaves meta to be replaced wholesale each time.
.scroll_with_wrapped and try_scroll_wrapped are the resolver-based and
fallible siblings, matching .scroll_with / try_scroll.
A type outside this crate's pagination module - a third-party
paginator, a hand-rolled cursor - can describe itself to .scroll
by implementing ProvidesScrollMetadata instead of building
ScrollMetadata field by field:
use ;
new.scroll
LengthAwarePaginator, Paginator, and CursorPaginator implement it too - see Pagination.
Dot-notation nesting
A key containing . nests into the response instead of shipping as a
literal string key - Laravel's Arr::set-backed dot notation
(Inertia::share('user.name', …), resolveArrayableProperties):
new
.with
.with
ships as:
not two literal "user.name" / "user.locale" keys. Two calls sharing a
prefix accumulate into one object; a key with no dot is unaffected. This
applies to every prop-attaching method - .with, .always, .lazy,
shared-registry keys - and to nothing else: it never recurses into a
prop's value, so a validation errors object keeps whatever dotted
field names it carries internally. There is no escape hatch for a key
that must keep a literal dot (.with("config.json", …) still nests) -
this matches Laravel, where Arr::set has no escaping mechanism either.
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 reads one thing: the prop's visibility, set by .always(),
.optional(), or .defer(). A prop with none of those has the default
visibility.
- Default-visibility props follow whitelist / blacklist semantics.
.always()props are sent regardless..optional()and.defer()props never ship on a standard visit, and only appear on a matching partial reload that explicitly lists the key.
The merge and scroll flags do not enter into it: they decide how the
client folds a value it receives, not whether it receives one, so a
.defer().merge() prop filters exactly like a plain .defer() one.
.once() doesn't enter into it either, though it isn't purely a folding
instruction - on a full visit where the client reports the value already
cached, the server skips the resolver and sends no value, as the note
below describes. What all three change is which metadata blocks ride
along - see Composing flags on one prop.
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.
A once prop's client-side cache is honoured only on a full Inertia
visit. On a partial reload that names the key
(router.reload({ only: ['stats'] })), the resolver runs and the value is
sent - the client asked precisely because it wants a fresh one, and
honouring its stale-cache claim there would return nothing at all for the
key it asked for.
Nested only/except (dot notation)
X-Inertia-Partial-Data and X-Inertia-Partial-Except entries can name a
path inside a prop's value, not just the prop's own key. A client calling
router.reload({ only: ['user.name'] }) sends
X-Inertia-Partial-Data: user.name, and the response narrows the user
prop down to just that field:
except prunes the same way instead of narrowing - router.reload({ except: ['user.email'] }) leaves every other field of user in place.
Rules:
- A bare entry (
user) still means the whole prop. Ifonlynames bothuseranduser.name, the whole value ships - the bare entry wins. - An entry can also name an ancestor of a dotted prop key. A prop
registered under
auth.user- by.with("auth.user", …)orApp::inertia_share("auth.user", …)- participates inonly: ['auth'], and ships whole, because the caller asked for the wholeauthroot. A bareexcept: ['auth']drops it for the same reason. The prefix has to end on a segment boundary, so an unrelatedauthAgent.userprop is untouched by either. exceptwins on a path both headers name, the same way it wins at the top level.- A path that doesn't resolve against the value - an unknown field, or one that drills through a scalar or an array instead of an object - contributes nothing for that path, without dropping the sibling fields requested alongside it.
Alwaysprops ignoreonly/exceptentirely, dot notation included - they always ship whole.OptionalandDeferprops still need the explicit request to resolve at all. A dotted entry (permissions.read) counts as that request for the top-level key, and the resolved value narrows the same way anEagerprop's does.- A dotted
onlyagainst a prop whose current value isn't an object - a string, a number, an array - narrows to{}, not to the original value. The client's reconciliation only deep-merges when both the cached value and the incoming one are objects (inertia-3.6.1/packages/core/src/response.tsnestedTopKeys); an empty object fails that check against a non-object cache the same way a populated one would, so the empty object replaces the cached scalar outright instead of merging onto it. Avoid sending a dotted request against a prop that isn't shaped as an object. - A dotted
exceptdoesn't delete the field on the client - it stops the field from refreshing on this response, and the client's merge restores it from whatever it already had cached.deepMergeObjectsbuilds the merged object by cloning the cached value first and then only overwriting the keys the server actually sent; a key the server pruned is never touched, so it survives with its old value. On a client's first-ever load of that prop (nothing cached yet) the pruned field is genuinely absent, since there's no cache to fall back to - the "restores from cache" behavior only applies to a page the client has already seen.
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;
Shared keys nest on dots the same way .with does - two static shares
under "user.name" / "user.age" land in one user object on the wire.
Read a shared value back, or clear the static registry entirely, with
App::inertia_shared / App::flush_inertia_shared - Laravel's
Inertia::getShared / Inertia::flushShared:
use App;
inertia_share;
assert_eq!;
flush_inertia_shared;
assert_eq!;
inertia_shared reads the static registry only - it returns None for a
key registered via inertia_share_lazy / inertia_share_once (there's no
request to resolve one against, mirroring Laravel's getShared, which
returns the raw closure rather than invoking it) and for a per-request
trait-provider share. flush_inertia_shared clears only the static
registry too; a provider registered via register_inertia_shared has no
per-request state to flush.
For per-request shared data (the authenticated user, request-scoped
flags), implement InertiaSharedData and
register the singleton - the framework calls share(&req, component) on
every Inertia response and merges the result. component is the page
being rendered, so a provider can vary its output by page - see below.
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
and the page component name - Laravel's RenderContext (component,
request), passed as a plain parameter rather than a wrapper struct
since the request already covers the other half. 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;
Ignore component (_component) if your provider doesn't need to vary by page.
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.
Validation failures
When a handler fails validation on an Inertia visit, the framework
answers 303 See Other back to the form page with the errors flashed,
instead of the 422 JSON a REST client gets. That is not cosmetic: the
Inertia client treats any response without an X-Inertia header as
non-Inertia and renders it in the full-screen error modal, so a 422
never reaches form.errors. Nothing in the handler changes - the bridge
is one of the middlewares Inertia::install registers.
The destination is the request's Referer when it is same-origin, then
the session's recorded previous URL, then the failing request's own URL.
A cross-origin Referer is ignored rather than followed, and so is one
that only looks same-origin: a leading // or /\ (a browser reads
either as protocol-relative once it folds a backslash into a slash) and
any ASCII control byte anywhere in the value (the URL parser strips tab
and newline from the whole string before it compares origins, so a
control byte can turn what looks like a safe path into a different
origin by the time a browser navigates it) both fall back the same way.
The same check applies to the final URL fallback too, so even an
unusual request path can't become an off-origin redirect.
A field's value is its first message, a plain string - the shape
Inertia's own ErrorValue type describes and what
$page.props.errors.email binds to. Set
InertiaConfig::with_all_errors(true) to get every message as an array
instead; the client-side type then needs the matching augmentation:
// global.d.ts
import '@inertiajs/core'
declare module '@inertiajs/core' {
export interface InertiaConfig {
errorValueType: string[]
}
}
Multiple forms on one page stay isolated: send
X-Inertia-Error-Bag: <name> with the visit and the errors are flashed
under that bag and read back under it, arriving as errors.<name>.<field>.
The errors prop is always-visible by default, so a partial reload
never filters or narrows it. only: ['users'] still ships the bag, and
so does except: ['errors']; only: ['errors.email'] ships the whole
bag rather than just that field. This is Laravel's shape - its
middleware shares the bag as Inertia::always(...), and resolveAlways
re-injects the raw value after the only/except rebuild. It matters
because the client folds a partial response in with
{...current.props, ...response.props}: an empty errors object would
wipe the messages already on screen, where an unfiltered one leaves them
correct. The rule covers both sources - the session-flashed bag and a
handler's own .with("errors", …). An explicit visibility flag still
wins, so .prop("errors", Prop::eager(…).optional()) behaves optionally.
Two things this does not do. It does not re-flash old input - the request
body is already consumed by the time the bridge runs, and an Inertia
useForm keeps its own state across a failed submit, so there is nothing
to repopulate. And it never touches a Precognition response: a dry-run
422 is exactly what the client asked for.
To send the visitor out of the Inertia app - a payment provider, an
OAuth authorize endpoint, a hosted billing portal - use location_for:
use ;
pub async
An Inertia XHR gets 409 + X-Inertia-Location (the client runs
window.location = url); a hard navigation gets a plain 302 + Location.
The bare InertiaResponse::location(url) always returns the 409 form - use
it only where the request is already known to be an Inertia visit, because
a browser that follows a 409 with no Location header has nowhere to go.
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.
The bounce re-flashes the session first. The client answers a 409 with a
full-page GET, and that GET is a fresh request - without the re-flash, a
validation error or success message flashed by the previous request is aged
away before the destination page can read it, and the user loses their error
message purely because a deploy landed mid-submit. This needs
SessionMiddleware registered ahead of the version middleware.
By default you set nothing: InertiaConfig hashes your Vite build
manifest (manifest_path, default public/assets/.vite/manifest.json)
and uses the first 16 bytes of its SHA-256, hex-encoded. The manifest is
the one file that changes on every build and on no other occasion, so
the version bumps itself. When there is no manifest to read - local
development, where Vite serves from memory - it falls back to the static
string "1.0" and logs at debug.
Override it when you want something else:
use ;
// Default - hash the build manifest. Nothing to write.
let cfg = new;
// A different manifest location; the version follows it.
let cfg = new.manifest_path;
// Static - bake in a build-time identifier. Survives a later
// `.manifest_path(...)` call: an explicit version is deliberate.
let cfg = new.version;
// Dynamic - a container deployment id, anything. The closure runs on
// every version check; cache inside if it isn't cheap.
let cfg = new.version_with;
The manifest is read on every version check, which is what Laravel's
hash_file does too - a few KB out of the page cache, and a rebuild is
picked up immediately. If you have measured that and want it gone,
resolve once at boot:
use ;
let version = from_manifest.resolve;
let cfg = new.version;
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 protocol middlewares in one call, from
register_http_stack - the HTTP-only bootstrap hook, which the server
path runs and the queue, schedule, workflow, and console binaries skip
(see Bootstrap):
use ;
Anything the Inertia layer depends on - SessionMiddleware - and
anything an error page needs to read - LocaleMiddleware - goes above
this call. See the ordering rules below.
// cmd/main.rs
new
.bootstrap
.http_bootstrap
Keep it out of bootstrap::register. Inertia::install fails closed in
production when the built frontend manifest is missing, which is exactly
the state of a worker or console image that ships no public/assets -
so installing it from the process-wide hook takes those binaries down
with it.
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
InertiaHeadersMiddleware- setsVary: X-Inertiaon every response and turns an empty200on an Inertia visit into a303back. - Registers
InertiaVersionMiddleware- emits the409+X-Inertia-Locationwhen client and server disagree on the asset version. - Registers
Inertia303Middleware- upgrades302to303on non-GET Inertia redirects. - Registers
InertiaValidationRedirectMiddleware- turns a422on an Inertia visit into a303back to the form page with the errors flashed. See Validation failures. - Registers
InertiaErrorPageMiddleware, only whencfgnames an.error_page(...)- turns the framework's own error responses into that page. See Error pages. If you registered one yourself, further out, yours keeps its position and the component it names, and this step is skipped - see Where the page is rendered.
Order matters: the headers middleware is registered first, so it is the
outermost and sees every response - including the 409 the version
middleware returns before the handler ever runs. The validation-redirect
middleware is registered last, so it is innermost - closest to the
handler - and sees a 422 before the other three middlewares get a
chance to touch it.
install also retains the config. Every InertiaResponse built
afterwards starts from it, so .frontend(...), .version(...),
.default_title(...), .ssr(...) and .encrypt_history(...) set here
reach every page without a handler passing anything. A handler that wants
different settings for one page still overrides with .with_config(...);
an app that never calls Inertia::install gets InertiaConfig::default();
and calling install again replaces the retained config.
.with_config(...) replaces the config wholesale, version included.
InertiaVersionMiddleware still resolves the version Inertia::install
was given, so a config here that doesn't carry the same .version(...)
makes the page object advertise a version the middleware will bounce - the
client takes one extra full page load after visiting that page. Set
.version(...) on the override to match.
Register SessionMiddleware ahead of Inertia::install if you use
flash data. The version middleware re-flashes the session before bouncing
the client, so a flashed error survives the follow-up full-page GET; it
can only do that inside a session scope.
Register LocaleMiddleware ahead of it too, if you
use an error page. A middleware's post-next code runs
after everything inside it has already returned, so the error-page
middleware renders once any scope opened inside it has been popped -
which for the locale middleware means the page would get the app's
default locale instead of the visitor's. The Inertia layer reads nothing
from localization, so putting locale outside it costs nothing. The
scaffolded bootstrap.rs already does this. The same reasoning applies
to any middleware of yours whose request scope the error page needs to
read.
Everything you register after this call is covered by the error page;
anything above it is not, because a middleware that answers without
calling next hands its response to nothing inside it. If your
CsrfMiddleware, rate limiter, or auth guard has to sit above the
install, register the error-page middleware yourself between them - see
Where the page is rendered.
Skip the call only if you genuinely don't want one of these middlewares
(rare; each of them closes a real failure mode - cache poisoning across
the two representations of a URL, silent stale-bundle,
form-replay-on-redirect,
and a validation 422 dead-ending in the client's error modal instead of
reaching form.errors).
Error pages
An Inertia visit that gets back a non-2xx from the framework does not show an error page - it shows a crash screen:
All Inertia requests must receive a valid Inertia response, however a
plain JSON response was received.
The client checks one thing before it will render anything: an
X-Inertia: true header on the response. A 403 from an
authorization check or an RBAC permission
middleware, a 404 for an unrouted path, a 429 from the
rate limiter, a 500 from a
failing handler - all of them carry the framework's JSON
error body and no such header, so the client hands them to its modal. A user with the wrong role clicks a nav
link and the app appears to break.
Name a page component and the framework renders those responses through it instead, keeping the status code:
use ;
"Error" is resolved exactly like any other page name, so
frontend/src/pages/Error.svelte (or .tsx, or .vue) is all it takes.
The three starters ship one and set .error_page("Error") already -
a new project is covered without doing anything.
Where the page is rendered
Inertia::install registers InertiaErrorPageMiddleware innermost of
the Inertia layer, so it sees the response the handler and the route
middleware actually produced. Everything you register after that call is
covered too - which is why the scaffold puts CsrfMiddleware below it.
Anything registered above the call is not covered. A middleware that
answers without calling next hands its response to nothing registered
inside it, so its rejection never reaches the Inertia layer at all. The
case that bites is a lapsed session posting a form: CsrfMiddleware
answers 419 with {"message":"CSRF token mismatch."}, and if it sits
above Inertia::install the user gets the crash modal on the one flow
they are most likely to hit. An outer rate limiter's 429 and an auth
guard's 401 behave the same way.
Register the middleware yourself when that is your shape, outside the
middleware whose rejections it should cover. This worked in 1.3.6 as a
side effect - the type was public and global registration is idempotent
per type, so an earlier registration kept its place - but nothing said so.
It is a documented contract from 1.3.7: install checks for your
registration, logs at debug, and skips its own.
use ;
Inertia::install sees the registration, skips its own, and says so at
debug. The position you chose is the one that stands, and so is the
component you named - that instance is the one in the chain. You name the
page once, at your own registration, which makes .error_page(...) on
the config optional here: keep it or drop it, nothing else reads it. It is
still what makes install register a middleware for an app that does not
place one itself.
Two ordering rules come with placing it yourself.
After SessionMiddleware and LocaleMiddleware.
The page carries your shared props - auth.user, flash, the locale share -
and it is built on the way out, after every middleware registered inside
it has returned and popped whatever request scope it opened. Registered
above those two, every error page loses the visitor's session and renders
in the app's default locale rather than theirs. The same holds for any
request-scoped middleware of your own whose state the error page's shared
props read.
Before the middleware whose rejections it should cover, and no further out than that. Every response that passes through it is one more body it has to classify, and a middleware outside it can still answer before it runs.
If you register nothing yourself, Inertia::install does all of this for
you - and the scaffolded bootstrap.rs already has SessionMiddleware
and LocaleMiddleware above the call and CsrfMiddleware below it.
What the page receives
| Prop | Type | Always present | What it is |
|---|---|---|---|
status |
number |
yes | The original HTTP status - 403, 404, 500. |
message |
string |
yes | The error body's message, or the status's reason phrase when it carried none. Already sanitized: a 5xx reads "Internal Server Error", never the underlying error - and that holds under APP_DEBUG=true too. The dev-only debug_message field the JSON path adds there is deliberately not read, so the raw error stays in the log and the JSON response and never renders into a page. |
request_id |
string |
no | Present only when the error body carried one. The same id the structured log records, so the page can show a reference the operator can search. |
<script lang="ts">
interface ErrorProps {
status: number
message: string
request_id?: string
}
let { status, message, request_id }: ErrorProps = $props()
</script>
<h1>{status}</h1>
<p>{message}</p>
{#if request_id}<p>Reference: {request_id}</p>{/if}
Declare the props in the component rather than importing them from
types/inertia-props.ts: suprnova generate-types rewrites
that file from your own #[derive(InertiaProps)] structs, and these
props come from the framework.
What survives the swap
The status code is kept, and so is every header the original response set, except two groups.
What described the body being replaced. Every Content-* field
(Content-Length on a page four times the size of the JSON it replaced
is a framing bug) and Transfer-Encoding.
Content-Security-Policy is carved out of that rule by name - it shares
the prefix by historical accident and is response policy, not
representation metadata.
What governed how that body could be stored. Cache-Control,
Expires, Age, ETag, Last-Modified. The page carries your shared
props - auth.user, flash, the locale share - where the error body it
replaced was the same for everyone, so it must never inherit permission
to be stored by a shared cache and handed to a different visitor, nor
validators that belong to an entity it is not. The page sets
Cache-Control: no-cache, private for itself instead, the same default
Laravel gives a session-bearing response.
Everything else carries: Retry-After on a 429 still tells the client
when to come back, WWW-Authenticate on a 401 still carries the
challenge, and Vary, Set-Cookie, and your request-id header all
arrive intact. The rule is stated as what gets dropped rather than what
gets kept, so a header the framework has never heard of survives instead
of silently disappearing.
Both audiences are covered. An Inertia XHR visit gets the JSON page
object with X-Inertia: true; a hard navigation - someone pasting
/admin/articles into the address bar - gets the full HTML shell, the
same one a first load of any page gets. So the error page works whether
the user arrived through the SPA or not.
What it never touches
The middleware only stands in where nobody else has an answer. It leaves alone:
- Validation
422s.InertiaValidationRedirectMiddlewareowns those - see Validation failures. A422that survives that middleware (noerrorsobject, or a Precognition dry-run) keeps its body too. - Anything carrying
X-Inertia-Location. The409version bounce, and theredirect_toform of the RBAC middlewares. The client acts on the header, not the body. - Redirects. Only
400-599is in scope. - API clients. A request whose
Acceptprefersapplication/jsonovertext/htmlkeeps the JSON contract it has always had.curl's*/*counts as no preference, so it keeps JSON too. Only an Inertia visit or a browser navigation gets a page. - Responses that already are Inertia pages. A handler that rendered
its own page and gave it a
410keeps its own component. - Bodies that are not the framework's error shape. Your own HTML
error page, plain text that is not the router's own
404 Not Found, or a JSON envelope keyed differently - none of those is overruled. - Everything, when
error_pageis unset. The middleware is not registered at all, so an app that has not opted in runs exactly the code it ran before.
Which bodies get rewritten
The gate is the shape of the body, not who wrote it. At a 400-599
status, exactly three shapes are replaced:
- an empty body;
- a JSON object whose
messageis a string - the framework's own error envelope, and anything else shaped like it; - the router's fixed
404 Not Foundplain-text body.
Everything else passes through. That means a 401 a middleware of yours
answers with HttpResponse::json(json!({ "message": "Unauthenticated." }))
does become the error page - which is the point, since that is exactly
the response the client would otherwise modal - and it means only
message and request_id survive into the props. An envelope carrying
errors, code, or anything else loses those fields when it becomes a
page.
If a middleware of yours must keep its own JSON body on an error status,
give it a shape the gate does not match - key the human-readable text as
something other than message - or set X-Inertia: true on the response
yourself, which marks it as already being an Inertia response and takes
it out of scope. Both are one line at the point that builds the response.
One gap worth knowing: a handler that panics is out of reach. The
panic net wraps the whole middleware chain, so the synthesized 500 is
built after every middleware frame has already unwound. Panicking
handlers still surface the client's modal. Return Err(...) rather than
panicking (see Errors) and the error page covers it.
If the page itself fails to render - the component cannot be resolved,
SSR is down, a shared prop errors - the framework logs a warn with the
request id and returns the original error response. A broken error page
never masks the error it was rendering.
Why Suprnova diverges
Laravel puts this in the exception handler: you edit
bootstrap/app.php, match on the status yourself, and call
Inertia::render('Error', ['status' => $response->getStatusCode()])
with $response->setStatusCode(...) to put the code back. That is
flexible, and it is also a piece of framework plumbing every project
rewrites by hand, usually after seeing the modal in production first.
Here it is one config line, because the decision is the same for every
app: an Inertia visit or a browser navigation gets a page, an API client
gets JSON, and everything another contract owns is left alone. The
trade is that the rule is a fixed one rather than a match you write, so
opting a particular response out means giving it a body the gate does not
recognize, or marking it as already-Inertia - see
Which bodies get rewritten.
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 you
hand to Inertia::install - that config is
what every response starts from, so there is nothing to plumb through
your handlers:
install?;
SSR is off by default, and it is a property of the config: on for every
response built from the installed config, off for any response that
overrides with a .with_config(...) which doesn't set it. When enabled,
the framework posts the page
object to <url>/render and inlines { head, body } in the HTML
shell. A worker head that carries its own <title> - which is every page
using Inertia's Head component - replaces the shell's title rather
than joining it, and that means both .default_title(...) on the config
and a per-response .title(...): a document with two titles shows the
first one, so the shell's would win over the page's real one in the tab,
in search results, and in every link preview. With SSR on, set the title
in Head rather than on the response. A head with no title leaves the
shell's title exactly where it was. 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.
Before it dispatches at all, the gateway can check that the built SSR
bundle exists on disk - opt in with .ssr_bundle_path(...), pointed at
the conventional frontend/bootstrap/ssr/ssr.js (the check itself is on
by default, .ssr_ensure_bundle_exists(true), but has no effect until a
path is set - this is deliberately not auto-detected, so enabling SSR
against a test double never has to also stub a bundle on disk). A
missing bundle falls back to CSR immediately, without paying
ssr_timeout on a connection that was never going to succeed. This
mirrors Laravel's ensure_bundle_exists config.
install?;
suprnova new scaffolds frontend/src/ssr.{ts,tsx} and a build:ssr
npm script for every starter. Build it, then boot the worker:
&&
suprnova ssr:check verifies the worker is actually answering - it
hits the worker's own GET /health route, which every createServer()
bundle exposes without any extra code.
Configuration
Inertia behaviour is configured programmatically via InertiaConfig, and
the config you hand to Inertia::install is
the one every response starts from. The one env var the framework reads
directly is SUPRNOVA_FRONTEND (svelte / react / vue), and it only
supplies the default entry-point filename and page-component extensions
when the config doesn't say - an explicit .frontend(Frontend::React) on
the installed config wins, and is what suprnova new --frontend react
scaffolds. 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
.with_all_errors // one message per field, or all
.url_resolver // how `page.url` is derived
.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 |
Two attributes of the HTML shell are worth calling out.
<title> comes from .title(...) on the response, or from
.default_title(...) when the response set none. Under SSR the
page's own head wins over both: a worker head carrying a <title> is
the document's only one, and the shell leaves its title out entirely.
<html lang="..."> is the one attribute you cannot set, because the right
value is already known - it is the locale in effect for the request, what
LocaleMiddleware detected or the configured APP_LOCALE when nothing
did. See Localization; a screen reader takes its voice
from that attribute and a search engine reads it as the page's language,
so an app serving more than one language no longer has to rewrite the
finished document to correct it.
The url field
page.url is the path and query string of the request
(/users?page=2&sort=name). The client writes it into history.state, so
it is what back/forward navigation and router.reload() replay - drop the
query and every paginated or filtered page silently resets to page one.
InertiaVersionMiddleware derives its X-Inertia-Location from the
request's path and query too, so by default a 409 asset-version bounce
lands the browser on exactly the URL the page object named.
Override the derivation with url_resolver when the URL the client should
record differs from the one that arrived - a locale prefix the SPA doesn't
route on, or a path a reverse proxy rewrote:
use InertiaConfig;
let cfg = new
.url_resolver;
The resolver reads the request through InertiaRequestExt, and applies to
every response built from the config you pass to
Inertia::install - the usual place for a
resolver that should apply app-wide. Override it for a single response
with InertiaResponse::with_config(cfg). A resolver changes page.url
only. The 409 bounce keeps naming the URL that actually arrived - that is
the URL the browser has to fetch - so with a resolver in place the two
deliberately differ.
The Vite manifest at manifest_path is loaded lazily on first request
and cached for the process lifetime - every response built from the
installed config shares that one cache, so the file is read and parsed
once. 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.
Nine 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. - An empty
200on an Inertia visit becomes a303, not a302. Laravel'sonEmptyResponsereturnsredirect()->back()(a 302) and relies on its later302 → 303conversion for PUT/PATCH/DELETE only. A substituted redirect is never a continuation of the original method - the client has to issue a GET - so Suprnova says303directly instead of leaving GET visits on a 302 the client would follow with the original verb. Inertia::location($url)is two methods here, not one.location(url)keeps Laravel's always-409contract - it predates the request-aware form and pinned-tag consumers depend on that shape not changing.location_for(&req, url)is the newer, request-aware form:409for an Inertia XHR, plain302for a hard navigation. Reach forlocation_forin new code.Inertia::clearHistory()is two methods here, not one, either..clear_history()on the builder marks a single response;App::clear_history()flashes the flag into the session so it survives a redirect. Laravel gets away with one method because it's already session-backed - Suprnova keeps the response-local form as the default (no session dependency) and makes the cross-redirect case an explicit opt-in instead..lazy()isn't Laravel'sInertia::lazy(). Laravel's method is deprecated and behaves likeoptional()-LazyPropis a straight alias forOptionalProp, skipped entirely on the initial visit (ResponseFactory.php:174-181). Suprnova's.lazy()is the plain-closure convention Laravel itself uses for a callable prop with no wrapper at all - included whenever partial-reload filtering lets the key through, standard visits included. Reach for.optional()for the initial-visit-skipped behavior the name "lazy" suggests if you're coming from Laravel.- Nested
only/exceptnarrow after resolving, not before. Laravel'sResponse::resolvePartialPropertieswalks the dotted path through the raw, not-yet-resolved prop array, so a path into aLazyProporDeferPropdegrades tonull- the walk hits an unresolved closure and stops (inertia-laravel-2.0.25/src/Response.php:273-297). Suprnova resolves every prop's value first - resolvers are async, so there's no synchronous point where they're all plain arrays the way Laravel sometimes has - then narrows the resulting JSON value. An unknown or type-mismatched nested path is dropped instead of sent back asnull, matching what the client's own reconciliation expects: it deep-merges a narrowed object onto what it already holds (inertia-3.6.1/packages/core/src/response.ts:414-425), and a straynullwould clobber a field the client already has instead of leaving it alone. .scroll_wrappedis opt-in, not automatic. Laravel'sInertia::scroll($value, $wrapper = 'data', …)nests every scroll prop's merge instruction under"data"by default, because a Laravel paginator resource typically returns{ data: [...], links: {...}, meta: {...} }and only the array should merge. Suprnova's built-in paginators hand back a bare row array (Vec<T>, no envelope), so.scroll/.paginatemerge at the prop's root, and.scroll_wrappedis there for the cases that need the nested path instead.- A wrapped scroll prop prefixes its
match_onfields for you. On a.scroll_wrapped("posts", "data")prop,match_on("id")emits"posts.data.id". Laravel emits the unprefixed"posts.id", which its own client then fails to line up against the merge target, so the match silently never fires. The nesting point is unambiguous here - a scroll prop has at most one wrapper - so Suprnova derives the prefix rather than making you type it. Write the bare field name, not the path.
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
