Litestar + Inertia.js Integration
litestar-inertia is the four-library story:
| Layer | Library | Role |
|---|---|---|
| Client SPA | @inertiajs/react / @inertiajs/vue3 / @inertiajs/svelte |
Page resolution, forms, navigation, shared data access; generated templates target Inertia v3 |
| Frontend build | vite |
Bundling, HMR, dev server, production build |
| Python bridge | litestar-vite |
VitePlugin + InertiaConfig, asset manifest, type generation, page-props codec |
| Server framework | litestar |
Routes, Controllers, Guards, DI, DTOs — returning Inertia responses |
Litestar routes produce page data, ViteConfig.inertia configures the response
layer, and the Vite-served client handles subsequent navigations. For Vite-only
configuration, use the sibling skill.
When this skill activates
- Python files importing
litestar_vite.inertia,InertiaConfig, or route handlers withcomponent= *.tsx/*.vue/*.sveltefiles importing from@inertiajs/*createInertiaApp({ resolve, setup })in a frontend entrypoint- A
resources/orresources/js/pages/directory alongside asrc/py/— classic litestar-vite + Inertia layout inertia.config.tsor anInertiaConfiginvocation invite.config.ts- User asks about "building an SPA with a Python backend", "server-driven React/Vue", "form validation errors from Python", "shared auth data across pages"
Code Style Rules
- PEP 604 unions in consumer Python modules; use
from __future__ import annotationsonly when the application benefits from it - TypeScript typed pages — generate page-props types via
litestar-vite's TypeGen, never hand-roll - Forms via
useForm— use the adapter form helper for errors, submission state, and navigation - CSRF via Litestar state — configure Litestar
CSRFConfigand wirecsrfHeaders()into global Inertia visit options; generated scaffolds already do this, including withcookie_httponly=True - Shared data for auth + flash, never page-specific. Static page props go in
InertiaConfig.extra_static_page_props; session-backed props go inextra_session_page_props; request-time flashes useshare(request, ...). - camelCase on the wire — define msgspec structs with
class Example(msgspec.Struct, rename="camel"); generated TypeScript consumes the serialized names - Partial reloads over full-page reloads when only a subset of props changes (
router.reload({ only: ['notifications'] })) - Lazy props for expensive-to-compute page data the user may not need on first paint
Quick Reference
Backend — Python route returning an Inertia page
from __future__ import annotations
from litestar import Controller, get
from app.domain.accounts.guards import requires_active_user
from app.domain.dashboard.schemas import Dashboard
class DashboardController(Controller):
"""Controller for user dashboard."""
path = "/dashboard"
guards = [requires_active_user]
@get("/", component="dashboard/Index")
async def index(self, dashboard_service) -> dict[str, Dashboard]:
"""Render dashboard page."""
return {"dashboard": await dashboard_service.get_for_current_user()}
→ See references/litestar_integration.md
Client — page component (React)
// resources/js/pages/dashboard/Index.tsx
import { usePage, Head } from "@inertiajs/react";
import type { Dashboard } from "@/generated/api";
export default function DashboardIndex() {
const { dashboard } = usePage<{ dashboard: Dashboard }>().props;
return (
<>
<Head title="Dashboard" />
<h1>Welcome, {dashboard.user.name}</h1>
<p>Your workspace has {dashboard.workspaceCount} projects.</p>
</>
);
}
→ See references/protocol.md
App wiring — VitePlugin owns the Inertia bridge
from __future__ import annotations
from litestar import Litestar
from litestar.middleware.session.client_side import CookieBackendConfig
from litestar_vite import (
InertiaConfig,
InertiaSSRConfig,
PathConfig,
TypeGenConfig,
ViteConfig,
VitePlugin,
)
from app.domain.accounts.schemas import CurrentUser
from app.lib.settings import get_settings
settings = get_settings()
session_backend = CookieBackendConfig(secret=settings.secret_key.encode("utf-8"))
vite = VitePlugin(
config=ViteConfig(
mode="hybrid",
dev_mode=settings.debug,
paths=PathConfig(
root=settings.base_dir,
resource_dir="resources",
bundle_dir="public",
),
inertia=InertiaConfig(
root_template="index.html",
extra_static_page_props={"appName": settings.app_name},
extra_session_page_props={"currentUser": CurrentUser},
precognition=True,
ssr=InertiaSSRConfig(
enabled=True,
url="http://127.0.0.1:13714/render",
command=["node", "resources/ssr.js"],
),
),
types=TypeGenConfig(output="resources/generated"),
)
)
app = Litestar(
route_handlers=[DashboardController],
plugins=[vite],
middleware=[session_backend.middleware],
)
→ See references/litestar_integration.md for full wiring
Forms — useForm with Litestar validation errors
import { useForm } from "@inertiajs/react";
export default function CreateProject() {
const { data, setData, post, processing, errors } = useForm({
name: "",
description: "",
});
return (
<form => { e.preventDefault(); post("/projects"); }}>
<input value={data.name} => setData("name", e.target.value)} />
{errors.name && <div className="error">{errors.name}</div>}
<textarea value={data.description} => setData("description", e.target.value)} />
{errors.description && <div className="error">{errors.description}</div>}
<button type="submit" disabled={processing}>Create</button>
</form>
);
}
Inertia validation follows redirect-with-session semantics. Use error(request, field, message) and return InertiaBack(request), or install an exception
handler that performs that mapping. A raw 422 response does not populate the
next page's errors prop automatically.
Precognition — Real-Time Form Validation
from litestar import Request, post
from litestar_vite.inertia import InertiaRedirect, precognition
@post("/projects")
@precognition
async def create_project(data: ProjectCreateDTO, request: Request) -> InertiaRedirect:
"""Create a project for a non-Precognition submission."""
await project_service.create(data)
return InertiaRedirect(request, "/projects")
Set InertiaConfig(precognition=True). A request with Precognition: true
that passes DTO validation receives 204 No Content and skips the handler;
validation failures use the configured Precognition exception handler.
Partial reloads & Prop Helpers
from litestar import get
from litestar_vite.inertia import (
InertiaResponse,
always,
defer,
lazy,
merge,
once,
optional,
)
@get("/reports", component="reports/Index")
async def reports_page(reports_service) -> InertiaResponse:
"""Demonstrates all Inertia prop wrapper helpers."""
return InertiaResponse(
content={
"summary": await reports_service.summary(),
"auth": always("auth", {"canEdit": True}),
"settings": once("settings", reports_service.get_settings),
"comments": optional("comments", reports_service.get_comments),
"export": lazy("export", reports_service.export),
"stats": defer("stats", reports_service.fetch_stats, group="analytics"),
"items": merge("items", await reports_service.list_items(), strategy="append"),
}
)
Workflow
Step 1 — Wire the bridge
Register one VitePlugin(config=ViteConfig(inertia=InertiaConfig(...))). Add
session middleware when using session-backed props or redirect errors. Do not
register a second Inertia plugin: VitePlugin reads ViteConfig.inertia and
configures the bridge.
Step 2 — Define shared props
Put static values in InertiaConfig.extra_static_page_props. Put session-backed values in extra_session_page_props so the integration pulls them from request.session. For request-time flash/auth additions, call share(request, key, value) before returning an Inertia response. These are available on every page via usePage().props without threading them through each handler.
Step 3 — Set up the client entrypoint
resources/js/app.tsx (React) or equivalent: call createInertiaApp() with
resolvePageComponent(), render setup, and global visit options:
defaults: {
visitOptions: (_href, options) => ({
headers: csrfHeaders(options.headers ?? {}),
}),
}
Import csrfHeaders from litestar-vite-plugin/helpers.
Step 4 — Build page components
One .tsx / .vue / .svelte file per route, keyed by name. @get(..., component="path/Name") on the Python handler maps to resources/js/pages/path/Name.tsx.
Step 5 — Generate types
litestar assets generate-types (from litestar-vite) reads your Python msgspec/DTO schemas and emits TypeScript types the page components consume directly.
Step 6 — Validate
/returnstext/html(full initial render) on first visit- Subsequent navigations return
application/jsonwith Inertia envelope (X-Inertia: true) - DevTools Network tab shows
X-Inertia-*response headers - Invalid form input stores errors and redirects back; the next page response
exposes
errors - Stale asset-version
GETrequests return409plusX-Inertia-Location; non-GETrequests continue to the handler - Partial responses omit
deferredProps
Guardrails
- Don't mix Inertia and plain JSON API routes in the same app surface — pick one per domain. Mixing confuses auth, CSRF, and response shape expectations. If you need both, use separate route prefixes (
/api/*for JSON,/dashboard/*for Inertia). - Do not assume
useFormadds CSRF headers — wirecsrfHeaders()throughcreateInertiaApp({ defaults: { visitOptions } }); the shipped scaffolds do this. - Shared props must be cheap — session props are read on every page request. Cache user lookup; don't hit the DB for feature flags; use Redis for session state.
- Version strings matter — Inertia tracks an asset version; mismatched versions force a full page reload. Let
litestar-vitegenerate the version hash; don't hand-roll. - No mixed-framework pages — React + Vue in the same app breaks Inertia's resolver. Pick one adapter per project.
- Deep-link routes need real URLs — every Inertia page should have a Litestar route returning it. SPA-only client routes (React Router inside an Inertia page) exist but are an escape hatch.
- Don't forget the root template —
InertiaConfig.root_templatepoints at the template that mounts the SPA. Default isindex.html; Jinja-backed Inertia apps set a LitestarTemplateConfigas well.
Validation Checkpoint
Before shipping an Inertia-integrated Litestar app:
-
ViteConfig.inertiaconfigured withInertiaConfig(...) - One
VitePluginregistered for Vite + Inertia - Session middleware registered
- Static/session/request-time shared props have consistent shape across handlers
- Page components resolve via the resolver function (one place of truth for path→component mapping)
- TypeScript page-props types generated via
litestar assets generate-types - Forms use
useForm - Validation failures call
error()and redirect back, or an exception handler performs the same mapping -
csrfHeaders()is wired into global visit options -
dev_modetoggles correctly between dev (Vite HMR) and prod (manifest-resolved assets) - Production build emits a configured or
.vite/fallback manifest plus hashed bundles
Example — Authenticated dashboard with forms + partial reload
"""app/domain/projects/controllers.py"""
from __future__ import annotations
from litestar import Controller, Request, get, post
from litestar_vite.inertia import InertiaBack, error
from app.domain.accounts.guards import requires_active_user
from app.domain.projects.schemas import Project, ProjectCreate
from app.domain.projects.services import ProjectService
class ProjectsController(Controller):
"""Projects management controller."""
path = "/projects"
guards = [requires_active_user]
@get("/", component="projects/Index")
async def index(self, projects_service: ProjectService, request: Request) -> dict[str, list[Project]]:
return {
"projects": await projects_service.list_for_user(request.user.id),
}
@post("/")
async def create(
self,
data: ProjectCreate,
projects_service: ProjectService,
request: Request,
) -> InertiaBack:
if await projects_service.exists(name=data.name, owner_id=request.user.id):
error(request, "name", "You already have a project with this name.")
return InertiaBack(request)
await projects_service.create(data.to_dict(), owner_id=request.user.id)
return InertiaBack(request)
// resources/js/pages/projects/Index.tsx
import { useForm, usePage, router } from "@inertiajs/react";
import type { Project } from "@/generated/api";
export default function ProjectsIndex() {
const { projects, flash } = usePage<{ projects: Project[]; flash: { success?: string } }>().props;
const { data, setData, post, processing, errors, reset } = useForm({ name: "", description: "" });
const React.FormEvent) => {
e.preventDefault();
post("/projects", { onSuccess: () => reset() });
};
return (
<>
{flash.success && <div className="flash">{flash.success}</div>}
<form
<input value={data.name} => setData("name", e.target.value)} placeholder="Project name" />
{errors.name && <div className="error">{errors.name}</div>}
<textarea value={data.description} => setData("description", e.target.value)} />
<button disabled={processing}>Create</button>
</form>
<button => router.reload({ only: ["projects"] })}>Refresh</button>
<ul>
{projects.map((p) => <li key={p.id}>{p.name}</li>)}
</ul>
</>
);
}
References Index
- Inertia Protocol & Client — Protocol v3, request/response shape, React/Vue/Svelte adapter setup,
useForm,usePage,router, partial reloads, lazy props, SSR - Litestar Backend Integration —
InertiaConfig,component=route handlers, shared props, redirect responses, validation errors, type generation, SSR server
Cross-Skill References
../litestar-vite/SKILL.md— Vite plugin config,VitePlugin, asset manifest, TypeGen pipeline, HMR (the backbone Inertia sits on)../litestar/SKILL.md— Controllers, Guards, DI, DTO patterns (the request handling layer)../advanced-alchemy/SKILL.md— Data services that produce page props../msgspec/SKILL.md— Struct definitions that TypeGen consumes
Official References
- Inertia.js v3 docs: https://inertiajs.com/docs/v3
- Tagged Litestar-Vite Inertia docs: https://github.com/litestar-org/litestar-vite/tree/v0.31.0/docs/frameworks/inertia
- Client-side setup: https://inertiajs.com/docs/v3/installation/client-side-setup
- Release notes: https://github.com/inertiajs/inertia/releases
- Tagged
litestar-viteInertia source: https://github.com/litestar-org/litestar-vite/tree/v0.31.0/src/py/litestar_vite/inertia - Tagged Inertia tests: https://github.com/litestar-org/litestar-vite/tree/v0.31.0/src/py/tests/unit/inertia
Shared Styleguide Baseline
Keep this skill focused on the Litestar ↔ Vite ↔ Inertia integration surface. Framework-agnostic React/Vue/Svelte patterns belong in the respective framework skills (if we ever port them) or inertiajs.com docs.