Inertia server adapter (src/inertia/): rendering, props, SSR, Vite
This is a port of inertia-rails' Renderer, PropsResolver and SSR renderer for the Inertia.js v3 protocol. Flash, redirects, CSRF and security headers are covered in INERTIA_SECURITY.md.
Rendering
use crate::inertia::{Inertia, Props, defer, lazy};
async fn show(inertia: Inertia) -> loco_rs::Result<Response> {
inertia.render("Dashboard/Index", Props::new()
.prop("stats", lazy(|| async { Ok(json!({"n": 1})) }))
.prop("activity", defer(|| async { Ok(load_activity().await?) })))
.await
}Inertia is an extractor. It needs AppContext as router state and Arc<Settings> in shared_store.
- If the request has
X-Inertia: true, the response is the page as JSON and carriesX-Inertia: true. - Any other request gets the HTML document from
document.rs. - Both responses carry
Vary: X-Inertia, return status 200, and set theFlashConsumedresponse extension so B's flash layer clears the cookie.
The page object contains:
component,props,url(the original path and query),version,encryptHistory(settings.encrypt_history) andclearHistory.flash:{notice, alert}, only when at least one is set.sharedProps: the top-level shared keys,errorsfirst (see below).preserveFragment, only when true.- The metadata keys
deferredProps,scrollProps,mergeProps,prependProps,deepMergeProps,matchPropsOn,oncePropsandrescuedProps. Each is omitted when empty.
The errors prop
errors is always present and always included, even in a partial reload that doesn't ask for it.
- Its default value is
{}. - The value comes from the flash errors that
Redirect::errorsstored. - When the request has
X-Inertia-Error-Bag, or the flash stored anerror_bag, the errors are nested under that bag name. - It is a shared prop, as in inertia-rails'
inertia_shared_datawithalways_include_errors_hash = true: the errors hash is the base the app's shared props merge onto, sosharedPropsalways starts with"errors".
Props
| Builder | Ruby | First visit | Partial reload |
|---|---|---|---|
Prop::value(v) / any Into<Value> | plain | sent | sent if selected |
| `lazy( | async { .. })` | -> { } | |
always(..) / .always() | InertiaRails.always | sent | always sent |
optional(..) / .optional() | InertiaRails.optional | not sent | sent if selected |
defer(..) / .defer() / .group("g") | InertiaRails.defer(group:) | listed in deferredProps | sent if selected |
merge(..) / .merge() / .prepend() / .append_at(p) / .prepend_at(p) / .match_on(f) | InertiaRails.merge | mergeProps / prependProps / matchPropsOn | same (unless in X-Inertia-Reset) |
deep_merge(..) / .deep_merge() | InertiaRails.deep_merge | deepMergeProps | same |
once(..) / .once() / .once_key(k) / .expires_at(ms) / .expires_in(d) / .fresh() | InertiaRails.once | onceProps; skipped if in X-Inertia-Except-Once-Props | sent anyway when explicitly requested |
scroll(ScrollMetadata, ..) / .wrapper("data") | InertiaRails.scroll | scrollProps + merge at the wrapper | X-Inertia-Infinite-Scroll-Merge-Intent: prepend prepends; X-Inertia-Reset sets reset: true |
.rescue() | rescue: true | a failure is logged and listed in rescuedProps instead of failing the render | same |
A lazy closure is FnOnce() -> impl Future<Output = Result<impl Serialize>>. It runs at most once, and only when the prop is kept.
Closures returning props, arrays of props
These cover the reference resolver's recursive cases (props_resolver.rb#deep_transform_props / transform_array):
lazy_prop(|| async { Ok(..) })is a Ruby-> { }whose result is a prop tree rather than JSON. It can return:- a prop wrapper (
defer(..),merge(..),once(..), ...). The wrapper is unwrapped once: its metadata is collected at the closure's path and its inclusion rules apply, so a closure returningdefer(..)is listed indeferredPropsand is not evaluated on the first visit; Props, whose children may be wrappers (their paths areparent.child);Vec<Props>/Vec<Prop>, an array (see below). A tree returned by a closure is resolved as an already-resolved parent: it is not filtered again by partial-reload keys, as in Ruby.
- a prop wrapper (
Prop::array(items)(orVec<Props>/Vec<Prop>viaInto<Prop>) is an array whose items may hold wrappers. Map items resolve at the indexed pathfoos.0.bar, soX-Inertia-Partial-Data: foos.0.barselects one item's field, whilefoos.barmatches nothing. Items that end up empty are dropped. Other items are evaluated. An array holding no wrapper or closure anywhere (Rails'needs_transform?) is left intact, exactly as the same data would be throughProp::value: empty objects are kept and partial reloads filter it the same way..rescue()on a prop inside such a tree reports the full dot path inrescuedProps.
Dot notation
"a.b" keys expand like props_resolver.rb#expand_dot_notation, before resolving:
- A non-dotted key whose existing and new values are both plain objects (a JSON object or a nested
Props) is shallow-merged, so"user.name"then"user": {..}, and the reverse order, both keep every key. - Walking a dotted key, a missing (or
null/false) parent becomes an empty map. A plain-object parent is extended. A plainlazy(..)parent (a Ruby-> { }) is evaluated right then and extended. - A parent that can't hold children (a scalar, an array, or a modified prop such as
defer(..)) makes the render fail, where Ruby raises.
Keys and partial reloads
- Keys can use dot notation (
"auth.user"), and aPropsvalue can be nested inside anotherProps. X-Inertia-Partial-DataandX-Inertia-Partial-Exceptfollow the dot-path rules ofprops_resolver.rb. A key selects its ancestors and all of its descendants, and filtering reaches inside plain JSON objects too.- These headers only apply when
X-Inertia-Partial-Componentmatches the component being rendered.
Shared props
Register a SharedProps(SharedPropsFn) in ctx.shared_store:
let f: SharedPropsFn = Arc::new(|parts, _ctx| Box::pin(async move { Ok(Props::new().prop("auth", ..)) }));
ctx.shared_store.insert(SharedProps(f));- It runs on every render.
- Page props override shared props key by key (a shallow merge).
- The top-level keys of the shared props, after
errors, are reported insharedProps.
This kit registers auth in src/auth.rs.
Head tags (meta.rs, inertia-rails server_head)
A port of InertiaRails::MetaTag and MetaTagBuilder, plus the renderer and helper parts:
use crate::inertia::{InertiaMeta, MetaTag};
inertia
.meta(
InertiaMeta::new()
.title("Dashboard")
.tag(MetaTag::new().attr("name", "description").attr("content", "…"))
.tag(MetaTag::new().tag_name("script").tag_type("application/ld+json")
.inner_content(json!({"@context": "https://schema.org"}))),
)
.render("dashboard/index", props)
.await- Head keys are generated as in Ruby:
title,meta-charset,meta-name-<parameterized>,meta-property-…,meta-http_equiv-…, or<tag>-<8 hex of sha256("k=v&…")>..head_key(k)overrides, and.allow_duplicates()adds the digest suffix. A tag with an existing head key replaces the earlier one.remove(key),remove_if(f)andclear()mirror the builder. - Scripts: a
scripttag is alwaystype="text/plain"unless it isapplication/ld+json(compared case-insensitively), so nothing executes. ld+json content is JSON-escaped for a<script>body. Other content and all attributes are HTML-escaped. - Structural keys are not attributes.
.attr("type", …)is the same as.tag_type(…), andtag_name,head_key,allow_duplicatesandinner_contentgo to their builders too, in any letter case, snake_case or camelCase. An attribute named like the head-key marker (inertia/data-inertia) is dropped. Sotypeis normalized in one place and appears exactly once in the HTML and the JSON. - Settings (
settings:):server_head: false(the default): the tags go into the props as objects (tagName,headKey, camelCased attributes) under_inertia_meta, and are marked with theinertiaattribute.server_head: true: the tags go in as HTML strings underhead, the prop Inertia v3'sserverHeadclient option reads, and are marked withdata-inertia.server_head: seouses the prop nameseoinstead.use_data_inertia_head_attribute: trueswitches the attribute todata-inertiawithoutserver_head.
- Reserved prop: with
server_headon, a page or shared prop named like the meta prop (headby default) makes the render fail, as invalidate_meta_prop!. - Title template:
ctx.shared_store.insert(MetaTitleTemplate(Arc::new(|title| title.map(|t| format!("{t} | Kit")))))ismeta_title_template. It receives the current title and is applied before serializing. A blank result leaves the title alone. - HTML: on client-rendered HTML responses the tags are also written into
<head>(inertia_meta_tags), and a server-managed<title>replaces the document's default one. On SSR responses the server writes only the tags the SSR head lacks, matched by theirdata-inertiahead key, plus the title when SSR rendered none. Nothing repeats, and nothing is lost if the client and server settings disagree. - Client (
frontend/entrypoints/app.ts, shared byinertia.tsxand the SSR entryssr.tsx):serverHeadcomes fromVITE_INERTIA_SERVER_HEADat build time. The YAMLserver_headreads the same variable at boot, so set it for both. With it on,@inertiajs/react3.7 renders theheadprop's HTML into the SSR head, applies it on hydration, and replaces it on every navigation. Its head manager keys tags bydata-inertia, so a page's<Head>tag with the same key, or any<Head title>, wins over the server's.e2e/head.spec.tschecks the initial HTML and navigation in both the CSR and SSR projects. - Object mode (
server_head: false, the kit default): the React adapter has no consumer for_inertia_meta. The tags are rendered by the server only: into the document head on CSR, and as missing tags on SSR. They are not updated on client navigation. Useserver_head: truefor tags that follow navigation, or render_inertia_metawith<Head>yourself (inertia-rails' cookbookMetaTagscomponent). - The meta prop is added after partial-reload filtering, so it is present on every response that has tags.
HTML document
document.rs mirrors the Rails kit's layouts/application.html.erb. It contains:
<title data-inertia>- the viewport and app meta tags
- the favicons
- the inline dark-mode script, which carries the CSP nonce
- the Vite tags
- the SSR head
Without SSR, the body is <script data-page="app" type="application/json" nonce=…> followed by <div id="app">.
The page JSON is made safe for a script context: <, >, &, U+2028 and U+2029 are escaped to \uXXXX.
The nonce comes from B's CspNonce request extension. If that extension is missing, no nonce attribute is written.
Vite
settings.vite.dev_server: true:
- The tags point at the dev server: the React Refresh preamble (inline, with the nonce),
@vite/client, the stylesheet andfrontend/entrypoints/inertia.tsx. - The version is
"dev".
settings.vite.dev_server: false:
public/vite/.vite/manifest.jsonis read once, at boot ininertia::install, and cached.- The tags are built from the manifest: stylesheets (the
application.cssentry plus the css of the entry chunk and its imports), the entry as atype="module"script, and amodulepreloadfor each static import. Everything is served under/vite/. - The version is the SHA-256 of the manifest bytes.
A missing manifest:
- In production it is a boot error.
- In other environments the app logs a warning and renders pages without asset tags.
Asset versioning (version.rs)
version::layer returns 409 with X-Inertia-Location: {app_url}{original path+query} for an Inertia GET whose X-Inertia-Version differs from the current version. The client then does a full page load.
B's flash layer keeps the flash on 409 responses.
SSR (ssr.rs)
settings.ssr.enabled switches SSR on for HTML responses. Inertia JSON visits never call the SSR server.
- Dev (
vite.dev_server): the page is POSTed to{vite.dev_server_url}/__inertia_ssr, which the@inertiajs/viteplugin serves. - Prod: the page is POSTed to
settings.ssr.url, which defaults tohttp://127.0.0.1:13714/render. - The response is
{head: [..], body}:headgoes into<head>, and replaces our<title>when it contains one.bodyis inserted as-is, because it already contains the page script and the root div.
- Any failure falls back to client rendering and logs a warning with the component name. Failures include a connection error, a timeout (
ssr.timeout_ms), a non-2xx status and a bad body. - For a non-2xx status only the status is logged, never the response body. Inertia's SSR error body echoes the page, including its URL, and a password-reset URL carries its
sid. - Node output is redacted too.
@inertiajs/core's SSR server prints render errors withURL: <page.url>. The spawned child's stdout and stderr are therefore piped, not inherited: each line goes throughrequest_log::redact_textintotracing(thessr_outputfield, at info for stdout and warn for stderr). In dev, Vite's/__inertia_ssrendpoint logs the same error through Vite's logger, whichvite.config.tsreplaces with a redacting one (redactSensitiveQueryValues, the same parameter rules).
Process. With ssr.enabled && ssr.spawn, the SsrSupervisor initializer runs {ssr.node} {ssr.bundle} as a child process.
- The child uses
kill_on_drop. - It is restarted with exponential backoff, from 1 s up to 30 s. The backoff resets after 30 s of uptime.
- It is killed in
Hooks::on_shutdown. - If the bundle file is missing, the supervisor logs one line and does nothing.
- The child listens on the port of
ssr.url, passed asINERTIA_SSR_PORTand read byfrontend/entrypoints/ssr.tsxwhen node starts (default 13714, bound to 127.0.0.1). The port is not baked into the bundle, so two apps built from one checkout can use different ports (SSR_URL).
Production turns spawn on by default (SSR_SPAWN). Set SSR_SPAWN=false to run node ssr/ssr.js yourself.
Boot
App::after_context calls inertia::install, which does three things:
- Parses and validates
settings:, and records whether the resolvedAppContext.environmentis production (Settings::production, never read from YAML). In production it refuses to boot when:secret_key_baseis shorter than 64 characters, is one of theconfig/development.yaml/config/test.yamlexamples, or containsdevelopment-secretortest-secret;app_urlis empty, not an absolute URL, or nothttps://.allow_insecure_http: true(ALLOW_INSECURE_HTTP=true, off by default) permitshttp://for trying a production build locally.
- Loads the Vite assets.
- Stores
Arc<Settings>inshared_store.
Public files (public.rs)
App::before_routes starts the router from inertia::base_router(), whose fallback serves public/. Loco's static middleware is disabled in every environment.
- It only runs when no route matched, so a file can never shadow a route. A path that has a route for other methods gets that route's 405.
- It serves only GET and HEAD. Any other unmatched request is a plain 404.
/vite/…getsCache-Control: public, max-age=31536000, immutable. Other files (/icon.png,/icon.svg,/robots.txt, the error pages) getpublic, max-age=3600.- A missing file, a directory, or any path with a dot-segment (
/vite/.vite/manifest.json) is a real 404 withpublic/404.htmlas the body andCache-Control: no-cache. Segments are checked after percent-decoding, asServeDirdecodes them, so/%2egit/config,/vite/%2evite/manifest.json, encoded separators (%2f,%5c), a backslash, a NUL and a malformed escape are 404s too. - It runs inside the whole middleware stack, so these responses get the security headers too.
Request logging (request_log.rs)
App::middlewares is Loco's default stack with its logger replaced by inertia::request_log::Middleware. It has the same name, the same server.middlewares.logger.enable switch, the same position and the same span fields, except that http.uri is redacted:
- The values of
sid,token,password*and*_tokenquery parameters (case-insensitive, including the innermost key ofuser[password]-style names) become[FILTERED]. - Other parameters are kept byte for byte.
redact_textapplies the same rules to?name=value/&name=valuepairs anywhere in a free-form line. The SSR child's output goes through it.
tests/inertia_a.rs runs the real stack against a failing SSR server that echoes the page, captures the formatted log output, and asserts that a known sid never appears. It also spawns node through ssr::spawn_redacted, once with a script that prints sid-bearing URLs and once with the real SSR bundle forced into a render error, and asserts that the captured tracing output is redacted.