Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Inertia

Arcature implements the server side of the Inertia v3 protocol natively. A stock official @inertiajs/react or @inertiajs/vue3 client talks to it without knowing Arcature exists.

There is no @arcature/client package, and there will not be one. The reasoning is in ADR 0001: everything Rust hands JavaScript goes as generated .ts files on disk under resources/js/generated/, not through a bundler plugin the framework has to keep alive.

The mental model

The browser’s Inertia client makes ordinary HTTP requests. On a first visit Arcature renders the initial HTML document with the page object embedded in it. On subsequent visits — requests carrying X-Inertia — it returns the page object as JSON. Same route, same handler, two representations.

Configuring it

InertiaConfig::new takes an asset version and a root-document renderer:

use arcature::assets::{Assets, AssetsConfig};
use arcature::inertia::{InertiaConfig, vite_root_document};

let assets = Assets::detect(&AssetsConfig::new())?;
let config = InertiaConfig::new(
    env!("CARGO_PKG_VERSION"),
    vite_root_document("Acme", &assets, "resources/js/app.tsx"),
)?;

Application::new()
    .routes(routes())
    .inertia(config)
    .build()

default_root_document(title) is the minimal renderer if you are not using Vite. with_shared(shared_props) registers props every page receives.

A root document is any Fn(ScriptBody) -> String. ScriptBody displays as the <script data-page> payload plus the mount <div>, and it also carries this request’s CSP nonce when SecurityHeaders::with_csp_nonce is installed. Both built-in renderers stamp it onto every tag they emit; a hand-written one has to stamp its own, and body.nonce_attribute() is the attribute (with its leading space, or empty when there is no nonce) to interpolate:

let config = InertiaConfig::new(env!("CARGO_PKG_VERSION"), |body: ScriptBody| {
    let nonce = body.nonce_attribute();
    format!(
        "<!doctype html><html><body>{body}\
         <script{nonce} type=\"module\" src=\"/js/app.js\"></script>\
         </body></html>"
    )
})?;

.inertia(config) is what installs InertiaLayer. Without it the Inertia extractor fails: a handler taking inertia: Inertia in an application that never called .inertia(..) returns 500 inertia adapter error. That is documented on the builder method and is worth remembering, because the failure looks like a handler bug rather than a wiring one.

Rendering

The untyped path takes any Serialize:

pub async fn index(inertia: Inertia) -> Result<Response> {
    let response = inertia
        .render("users/index", serde_json::json!({ "users": [] }))
        .await?;
    Ok(response)
}

The inertia! macro is sugar over it. It requires an in-scope binding literally named inertia, because it expands to a call on that name:

pub async fn index(inertia: Inertia, State(state): State<AppState>) -> Result<Response> {
    let db = state.db.as_ref().ok_or_else(|| not_found("no database"))?;
    let users = user::Entity::query(db).all().await?;
    inertia!("users/index", { users })
}

render_with_options adds page-level options (history flags, flash data); render_advanced takes a Props value for per-prop behaviour.

The first argument to InertiaConfig::new is the asset version — any string that changes when the built assets change. The Inertia client compares it and does a full page reload when it moves. A release tag or a manifest hash both work; a constant means the client never reloads on deploy.

The Client Exposure Firewall

Serialize does not mean “safe to send to a browser”. A domain model derives Serialize for a hundred reasons, and any one of them makes it one field reference away from the wire. Arcature makes browser exposure a separate, explicit opt-in.

Two macros grant it.

#[page("name")] declares a page’s prop struct:

#[arcature::page("users/show")]
pub struct ShowUserPage {
    pub user: UserResource,
    pub can_edit: bool,
}

#[resource] declares a value that nests inside page props:

#[arcature::resource]
pub struct UserResource {
    pub id: String,
    pub name: String,
    pub avatar: Option<AvatarResource>,
}

Both emit impl ClientData, whose exposure_schema() is built from the named fields. A non-primitive field type maps to PropsSchema::nested::<FieldType>, which requires FieldType: ClientData. So nesting an internal model inside a page does not compile — the failure is a trait bound at build time, not a leak in production.

#[page] additionally emits a PAGE_CONTRACT const (the typed handle) and a PAGE_CONTRACT_ENTRY const (the non-generic one module! aggregates). Both are &'static. Nothing registers itself; application! builds the PageContracts registry from the graph.

#[resource] emits no PAGE_CONTRACT: resources are values inside pages, not pages.

A database model is not a resource. A SeaORM entity stays an entity, and application code converts explicitly with impl From<User> for UserResource. The conversion is the place where you decide what the browser sees, which is the point of writing it out.

Rendering through the firewall

pub async fn show(inertia: Inertia) -> Result<Response> {
    let page = ShowUserPage {
        user: UserResource { id: "1".into(), name: "Ada".into(), avatar: None },
        can_edit: true,
    };
    Ok(inertia.render_page(ShowUserPage::PAGE_CONTRACT, page).await?)
}

render_page is render with a ClientData bound. The component name comes from the contract rather than a string literal, so a renamed page cannot drift from its route.

A controller method may instead return Page<T> and let #[controller] read the page identity off the return type — see Controllers. page!(ShowUserPage { .. }) constructs one with a compile-time ClientData assertion at the call site.

Prop behaviours

Props carries per-prop evaluation strategy, matching the Inertia protocol:

ConstructorBehaviour
eager(value)always serialized
always(value)included even in partial reloads
lazy(f)resolved only when requested
optional(f)omitted unless the client asks for it
deferred(f)sent in a follow-up request
deferred_group(name, f)deferred, batched under a group

merge(prop), prepend(prop) and deep_merge(prop) set the client-side merge strategy for a prop that accumulates across visits.

Contracts as an artifact

.page_contracts(artifact) publishes the collected page contracts as a request extension. It changes no response; it is data for the dev-only UAG endpoint and for arc typegen to read when generating TypeScript.

arc typegen reads that artifact and writes the TypeScript, which is the generated-types pipeline ADR 0001 describes and the reason the contracts are collected at all.

Redirects

inertia.redirect(location) builds an Inertia-aware redirect. external(url) produces a 409 with X-Inertia-Location, which is how the protocol tells the client to leave the SPA. fragment(..) targets a fragment.

One port

In development, Vite runs in middlewareMode with no TCP port of its own and the Rust process forwards to it over an IPC endpoint. There is one port in development and one in production, and no localhost:5173 fallback. See ADR 0003 and Deployment.

When a page does not need JavaScript

Inertia is for the application. For pages that are just HTML – a marketing page, a confirmation screen, an email body – the views feature renders Askama templates instead, and costs the client nothing.

Askama compiles a template into Rust at build time, so there is no expression evaluator anywhere in the request path and server-side template injection is structurally absent rather than defended against. The trade is that editing a template means rebuilding, and that a Dockerfile has to COPY templates before cargo build. arc make:view <name> writes a view struct and its template together.

The two mix freely: an application can serve Inertia pages behind sign-in and compiled views in front of it.

More than one language

The i18n feature adds Fluent translation catalogs and puts the negotiated locale where both renderers can reach it – Inertia props and view context alike, so a page does not have to know which one it is.

Fluent rather than a HashMap<String, String>, because the map is wrong the moment a language has more than two plural forms. The negotiation reads Accept-Language and any override you allow, and matches it against the locales you registered: a locale string never becomes a path, so a hostile Accept-Language selects nothing rather than reaching the filesystem.