Vukem ships its own Inertia v3 adapter in the inertia package, with starters for React, Vue and Svelte.
vukem new app --inertia react # or vue, svelte
vukem install:inertia --force --react # turn an HTMX starter into an Inertia app
vukem serve # Go app with reload + npm run devRendering pages#
func (p *PostController) Index(c *web.Ctx) error {
posts, err := p.posts.All(c.Context())
if err != nil {
return err
}
return inertia.Render(c, "Posts/Index", inertia.Props{
"posts": posts,
"filters": inertia.Optional(func(ctx context.Context) (any, error) { return loadFilters(ctx) }),
"stats": inertia.Defer(statsFn).Group("sidebar"),
})
}The starter wires it for you:
vukem.WithInertia(inertia.Config{}) // bootstrap/app.go → app.Inertia
r.Use(app.Inertia.Middleware, shareProps(app)) // routes/web.go: Vary, version check, 302→303, fragment redirectsinertia.RenderStatus(c, status, component, props) renders a page with another status, such as a 404, 410 or 503 error page: the Inertia client shows any response carrying X-Inertia as a page. Do not call c.Status before Render, which commits the headers. Statuses without a body (1xx, 204, 205, 304), redirects and 409 (reserved by the protocol) return an error.
return inertia.RenderStatus(c, http.StatusGone, "Errors/Gone", inertia.Props{"reason": "expired"})The root view resources/views/app.html holds the CSP nonce meta, {{vite}}, {{.Head}} in <head> and {{.Inertia}} in <body>. The initial page is a <script data-page="app" type="application/json"> with / escaped.
Props#
| Prop | Behaviour |
|---|---|
plain value or func(ctx) (any, error) | always sent; functions resolve lazily |
Optional(v) | only when a partial reload asks for it |
Always(v) | even when a partial reload excludes it |
Defer(v).Group(g).Rescue() | loaded after the first render, grouped; Rescue keeps the page alive if it fails |
Merge(v) with .Append(paths...) / .Prepend(paths...) / .MatchOn(fields...), DeepMerge(v) | merged into the client's current value |
Once(v).As(key).Fresh().Until(t) | sent once and remembered by the client |
Scroll(v, meta), ScrollPage(orm.Page), ScrollCursor(orm.CursorPage, name) | infinite scrolling |
Modifiers chain (Defer(v).Merge(), Merge(v).Once()). Nested maps resolve recursively, top-level "a.b" keys unpack, and partial reloads filter by dotted path (only, except, reset).
Shared props: inertia.Config.Shared for global ones, inertia.Share(c.Context(), key, value) per request. The starters share the auth user and flash messages.
Translations (i18n)#
inertia.I18n sends the i18n catalog to your components, so Vue and React use the same JSON files as the server:
locale, err := i18n.Middleware(bundle, i18n.MiddlewareConfig{}) // resolves the locale (cookie, ?lang=, Accept-Language)
if err != nil {
return err
}
r.Use(
locale,
in.Middleware,
inertia.I18n("ui.", "nav."), // only keys under ui.* and nav.* reach the browser
)Every page then gets three props:
| Prop | Example | Notes |
|---|---|---|
locale | "pt-BR" | the locale resolved for this request |
locales | ["en", "pt-BR"] | for a language switcher |
messages | {"nav.home": "Início"} | flat keys, with fallback to the default locale; plural messages are objects ({"one": "...", "other": "..."}) |
messages is a once prop keyed by locale: the browser downloads the catalog on the first visit and again only when the locale changes.
A small helper is enough on the client. It fills {name} placeholders and picks plural forms with Intl.PluralRules, like the server's i18n.T:
// resources/js/lib/i18n.ts
type Message = string | Record<string, string>
export function translate(
messages: Record<string, Message>,
locale: string,
key: string,
params: Record<string, string | number> = {},
): string {
let msg = messages[key]
if (msg === undefined) return key
if (typeof msg !== "string") {
const n = Number(params.count)
msg = (n === 0 && msg.zero) || msg[new Intl.PluralRules(locale).select(n)] || msg.other || key
}
return msg.replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m))
}<!-- Vue -->
<script setup lang="ts">
import { usePage } from "@inertiajs/vue3"
import { translate } from "@/lib/i18n"
const page = usePage()
const t = (key: string, params = {}) => translate(page.props.messages, page.props.locale, key, params)
</script>
<template><a href="/">{{ t("nav.home") }}</a></template>// React
import { usePage } from "@inertiajs/react"
import { translate } from "@/lib/i18n"
export function useT() {
const { messages, locale } = usePage().props as { messages: Record<string, string>; locale: string }
return (key: string, params = {}) => translate(messages, locale, key, params)
}To switch language, link to any page with ?lang=pt-BR (or set the locale cookie) and the next visit carries the new catalog.
Requests and redirects#
| Area | Support |
|---|---|
| Version | hash of the Vite manifest; a GET with an old version gets 409 + X-Inertia-Location |
| Partial reloads | Partial-Component, Partial-Data, Partial-Except, X-Inertia-Reset, error bags, prefetch (Purpose: prefetch) |
| Redirects | 302 → 303 after PUT/PATCH/DELETE; inertia.Location(c, url) forces a full visit (409 for external URLs); inertia.PreserveFragment(c) keeps the URL fragment |
| Validation | errors flashed and 303 back, surfaced as props.errors |
| Flash | inertia.Flash(c, key, value) fills page.flash once; survives redirects, not consumed by prefetch |
| History | Config.EncryptHistory (needs a secure context) or inertia.EncryptHistory(ctx, bool) per page; inertia.ClearHistory(c) on logout |
Precognition#
r.Post("/users", inertia.Precognitive(schema, h))Precognition: true requests only validate: 204 with Precognition-Success, or 422 {message, errors} filtered by Precognition-Validate-Only. Vary: Precognition is always set.
Server-side rendering#
inertia.Config{SSR: &inertia.SSRConfig{Bundle: "bootstrap/ssr/ssr.js"}}- Pages are POSTed to the Node SSR server (
/render, default127.0.0.1:13714, 3s timeout). Any failure falls back to client-side rendering and is reported toOnFailure(a warning log by default). - In development the Vite plugin endpoint (
{hot}/__inertia_ssr) is used. Exceptpath prefixes andinertia.DisableSSR(ctx)skip SSR per request.vukem ssr:startrunsnode bootstrap/ssr/ssr.jsand restarts it when it crashes;vukem ssr:checkprobes/health.
SSR is off by default.
Typed routes#
vukem codegen:routes # resources/js/routes.ts (-o file, -o - for stdout)import { route, routes, type RouteName } from "./routes";
route("posts.show", { id: 7 }); // "/posts/7"
route("files.show", { path: "a b/c" }, { page: 2 }); // "/files/a%20b/c?page=2"
routes["posts.store"].method; // "POST"The command boots the app in warmup mode (config and route definitions, no Boot, no connections) and writes only named routes. Params are typed per route; regenerate after changing routes and commit the file. For typed API calls, see client generation.
Testing#
app.Client(t).Inertia("").Get("/posts").
AssertInertia("Posts/Index").
HasProp("posts", 3).
MissingProp("stats").
PropEquals("filters.q", "go")Flash data (inertia.Flash) lives in page.flash, not in props. FlashEquals(key, v) / MissingFlash(key) on the page read only page.flash. AssertFlash(key, v) / AssertNoFlash(key) on any response also look in the session, so they work before the redirect is followed:
c := app.Client(t).Inertia("")
c.PostJSON("/posts", form).AssertRedirect("/posts").AssertFlash("status", "Post saved.")
c.Get("/posts").AssertInertia("Posts/Index").FlashEquals("status", "Post saved.")Works on first visits (HTML) and Inertia visits (JSON). Inertia("") sends the app's current asset version, so tests keep passing after bun run build; pass any other value (Inertia("stale")) to test the 409 version-mismatch reload. Inertia(version) also sends the Inertia Accept header, so PostJSON behaves like a form submission.