Inertia
Inertia.js is a protocol that bridges your server and frontend, letting you build full-stack single-page applications without building a separate API. Instead of writing REST endpoints and consuming them with fetch(), you write controllers that return component names and props — Inertia takes care of the rest.
For FastAPI developers, this means you get the speed and structure of server-side routing with the interactivity of a modern frontend, using your choice of React, Vue, or Svelte — with far less boilerplate than a traditional API-driven SPA.
The InertiaProvider wires Inertia into your application: it binds the Inertia renderer to the container, registers InertiaMiddleware, and injects the {{ inertia(page) }} Jinja2 helper into your templates.
Setup
Register InertiaProvider after ViteProvider, which provides the Jinja2 templates:
# bootstrap/application.py
from fastapi_startkit.inertia import InertiaProvider
from fastapi_startkit.vite import ViteProvider
app = Application(
base_path=...,
providers=[
ViteProvider,
InertiaProvider,
...
],
)Root Template
Create templates/index.html. The {{ inertia(page) }} helper renders the bootstrap <script> tag and the <div id="app"> mount point:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{ vite_react_refresh() }}
{{ vite('resources/js/app.tsx') }}
</head>
<body>
{{ inertia(page) }}
</body>
</html>This renders:
<script data-page="app" type="application/json">
{"component": "...", "props": {...}, ...}
</script>
<div id="app"></div>Rendering Components
Use Inertia.render() in your controller:
from fastapi import Request
from fastapi_startkit.inertia import Inertia
async def index(request: Request):
return Inertia.render(request, "Dashboard/Index", {
"user": {"name": "Alice"},
})- On the first page load, returns an HTML response using the root template.
- On Inertia XHR requests (
X-Inertia: trueheader), returns a JSON response with component name, props, and URL.
Shared Data
Share data globally — available as props on every component:
# providers/fastapi_provider.py or bootstrap/application.py
from fastapi_startkit.application import app
inertia = app().make("inertia")
# Static value
inertia.share("app_name", "PingCRM")
# Callable resolved per-request (receives the request object)
inertia.share("auth", lambda request: {
"user": request.state.user,
})
# Callable resolved once (no parameters)
inertia.share("flash", lambda: {})Shared data is merged with per-render props. Per-render props take precedence.
Partial Reloads
return Inertia.render('Users/Index', {
'users': lambda : await User.all(),
'companies': lambda await Organization.get()
})and it also provides an Inertia.optional() method to specify that a props should never be included unless requested using the only option.
return Inertia.render('Users/Index',{
'users': Inertia.optional(lambda : await User.all())
})Asset Versioning
Set a version string so Inertia can detect asset changes and trigger a full-page reload:
inertia = app().make("inertia")
inertia.version("1.0.0")
# Or tie it to the Vite manifest hash:
vite = app().make("vite")
inertia.version(vite.manifest_hash() or "1")When the client's X-Inertia-Version header mismatches the server version, the middleware returns 409 Conflict with an X-Inertia-Location header, causing the client to perform a full hard reload.
Middleware
InertiaMiddleware is registered automatically by InertiaProvider. It handles three concerns:
- Version check — returns
409on version mismatch (triggers hard reload). - Redirect conversion — converts
302redirects to303forPUT/PATCH/DELETErequests, so the browser performs aGETon the redirect target. - Vary header — adds
Vary: X-Inertiato every response for correct cache behaviour.
Custom Root View
Change the root template name (default: index.html):
inertia = app().make("inertia")
inertia.set_root_view("app.html")Client-Side Setup (React)
Create resources/js/app.tsx as the Inertia bootstrap entry point.
Basic setup
import '../css/app.css'
import { createInertiaApp } from "@inertiajs/react"
import { createRoot } from "react-dom/client"
const appName = import.meta.env.VITE_APP_NAME || "My App"
createInertiaApp({
title: title => `${title} - ${appName}`,
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
return pages[`./Pages/${name}.tsx`]
},
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />)
},
progress: {
color: "#F87415",
},
})Named routes
Inertia's client-side components often use a route() helper for generating URLs and checking the active route (e.g. for nav highlighting). Add it directly in app.tsx:
// Map your server-side route names to URLs
const routeMap: Record<string, string> = {
'dashboard': '/',
'login': '/login',
'users': '/users',
'organizations': '/organizations',
'contacts': '/contacts',
'reports': '/reports',
}
// Reverse map for current() lookup
const reverseRouteMap = Object.fromEntries(
Object.entries(routeMap).map(([k, v]) => [v, k])
)
function currentRouteName(): string {
const pathname = window.location.pathname
if (reverseRouteMap[pathname]) return reverseRouteMap[pathname]
const parts = pathname.replace(/^\//, '').split('/')
if (parts.length >= 3 && parts[2] === 'edit') return `${parts[0]}.edit`
if (parts.length >= 2 && parts[1] === 'create') return `${parts[0]}.create`
if (parts.length >= 2) return `${parts[0]}.show`
return parts[0] || 'dashboard'
}
window.route = function(name, params) {
let url = name ? (routeMap[name] ?? '/' + name.split('.')[0]) : '/'
if (params) {
const action = name?.split('.')[1]
if (action === 'edit') url += `/${params}/edit`
else if (action === 'destroy' || action === 'update') url += `/${params}`
}
const routeObj = new String(url) as string & { current: (pattern?: string) => string | boolean }
;(routeObj as any).current = (pattern?: string) => {
if (!pattern) return currentRouteName()
return new RegExp('^' + pattern.replace(/\*/g, '.*') + '$').test(currentRouteName())
}
return routeObj
}route().current() with no argument returns the active route name — used by nav components to highlight the current page. With a pattern argument it returns a boolean, e.g. route().current('users*').