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
Server-Side 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,
...
],
)Your backend is ready to serve Inertia pages, return the Inertia.render() response:
from fastapi_startkit.inertia import Inertia
async def index():
return Inertia.render("Dashboard/Index")Client-Side Setup
Inertia requires Vite for asset bundling. If you haven't set it up yet, follow the Vite docs first.
While Inertia supports Vue and Svelte as well, this guide focuses on React. Install the React adapter and the Vite plugin:
npm install react react-dom @vitejs/plugin-reactand for the types:
npm i --save-dev @types/react-dom @types/react// vite.config.js
import fastapi from 'fastapi-vite-plugin'
export default defineConfig({
plugins: [
fastapi({
input: 'resources/js/app.tsx',
refresh: true,
}),
react(),
// ...
],
})Entry point (resources/js/app.tsx)
import '../css/app.css'
import { createInertiaApp } from "@inertiajs/react"
import { createRoot } from "react-dom/client"
// @ts-ignore
const appName = import.meta.env.VITE_APP_NAME || "My App"
await createInertiaApp({
title: title => `${title} - ${appName}`,
resolve: name => {
// @ts-ignore
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",
},
})after defining the root component, define the pages as
// resources/js/Pages/Dashboard/Index.tsx
export default function Dashboard() {
return (
<div>
<p>Hello from the Fastapi Starkit</p>
</div>
)
}Root Template
Create resources/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>Passing Props
Use Inertia.render() in your controller. It takes the component name and an optional props dict — no request argument is needed, because InertiaMiddleware stores the current request in a context variable automatically:
from fastapi_startkit.inertia import Inertia
async def index():
return Inertia.render("Dashboard/Index", {
"user": {"name": "Alice"},
})Props are optional. When a controller has no props to pass, omit the second argument:
async def index():
return Inertia.render("Dashboard/Index")- 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. The right place to call share() is inside a provider's boot() method, after all providers have been registered:
# providers/fastapi_provider.py
from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider
from fastapi_startkit.inertia import Inertia
class FastAPIProvider(BaseFastAPIProvider):
def boot(self) -> None:
super().boot()
# Static value
Inertia.share("app_name", "PingCRM")
# Callable resolved per-request (receives the request object)
Inertia.share("auth", lambda request: {
"user": getattr(request.state, "user", None),
})
# Callable resolved per-request (receives the request object)
Inertia.share("flash", lambda request: {
"success": request.session.get("flash_success") if "session" in request.scope else None,
})You can also call Inertia.share() via the container binding (they are the same object):
inertia = self.app.make("inertia")
inertia.share("app_name", "PingCRM")Shared data is merged with per-render props. Per-render props take precedence.
Partial Reloads
Wrap prop values in callables so they are resolved lazily. On a partial reload only the requested keys are evaluated. Because async is not allowed inside a lambda, use a named async def for async props:
from fastapi_startkit.inertia import Inertia
from app.models import User, Organization
async def index():
async def get_users():
return await User.all()
async def get_organizations():
return await Organization.get()
return Inertia.render('Users/Index', {
'users': get_users,
'companies': get_organizations,
})Use Inertia.optional() to mark a prop as never included unless explicitly requested via the only option:
async def index():
async def get_users():
return await User.all()
return Inertia.render('Users/Index', {
'users': Inertia.optional(get_users),
})Asset Versioning
InertiaMiddleware automatically uses the Vite manifest hash as the asset version when a ViteProvider is registered — no extra configuration is needed in the common case.
To override with a fixed version string, call Inertia.version() in a provider's boot() method:
from fastapi_startkit.inertia import Inertia
class FastAPIProvider(BaseFastAPIProvider):
def boot(self) -> None:
super().boot()
Inertia.version("1.0.0")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):
from fastapi_startkit.inertia import Inertia
Inertia.set_root_view("app.html")