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:
| Constructor | Behaviour |
|---|---|
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.