Inertia basics
Inertia.js turns classic server-side routing into a single-page app without a separate API. The server returns a JSON payload describing which React page to render and what props to pass; the client's Inertia runtime swaps in the page component with a fetch-level round-trip. No bespoke API, no client-side router, no state-sync code.
What's on the server
Two moving parts:
fastapi-inertia— renders templates and serializes responses in Inertia's expected shape.InertiaLayoutDataMiddleware(framework) — attaches shared props (auth,menus,i18n) to every response.
Rendering a page
# modules/orders/orders/endpoints/views.py
from fastapi import APIRouter
from simple_module_hosting.inertia_deps import InertiaDep
from orders.contracts.schemas import OrderOut
from orders.deps import OrderServiceDep
router = APIRouter()
@router.get("")
async def browse(inertia: InertiaDep, service: OrderServiceDep):
orders: list[OrderOut] = await service.list()
return await inertia.render(
"Orders/Browse",
props={"orders": [o.model_dump(mode="json") for o in orders]},
)Breakdown:
InertiaDep(fromsimple_module_hosting.inertia_deps) is a request-scoped dependency that resolves theInertiainstance fromapp.state.inertia_dependency(configured at boot) and shares inrequest.state.inertia_shared.- The first argument to
renderis the page key —"Orders/Browse"maps tomodules/orders/orders/pages/Browse.tsx. See Pages & discovery. propsis a plain dict serialized to JSON. Usemodel_dump(mode="json")on SQLModel DTOs to get JSON-safe types (Decimal → str,datetime → iso string).
Responding with JSON vs. Inertia
endpoints/api.py— returns JSON. Called by background/non-Inertia code, or by Inertia'srouter.get/poston the client (which sendsX-Inertia: trueand receives a full Inertia response, not raw JSON).endpoints/views.py— returns Inertia pages. Routed atview_prefix, rendered as full HTML on the first request and as JSON page updates on client-side navigation.
The split is convention: keep GET-for-page-data calls in views.py, keep POST/PATCH/DELETE data mutations in api.py even if triggered from Inertia forms. This way JSON APIs are usable from scripts and Inertia pages route cleanly.
Inertia form submission
From a page, use Inertia's router.post:
import { router } from "@inertiajs/react";
function CreateOrder() {
const [data, setData] = useState({ customer_email: "", total: "" });
return (
<form
onSubmit={(e) => {
e.preventDefault();
router.post("/api/orders", data);
}}
>
...
</form>
);
}Inertia sends the request with the X-Inertia header and expects an Inertia response back (a redirect or a page render). Do not point Inertia's router.post at a JSON endpoint — fastapi-inertia sees a non-Inertia response and throws.
This is exactly what diagnostic SM018 warns about: an api.py route that returns JSON, called from a page via router.post/patch/put/delete. Either:
- Change the endpoint to live in
views.pyand returninertia.render(...)or a redirect. - Or change the page code to use
fetch(...)for a true JSON call.
Redirect-after-post
Inertia expects a redirect response (302/303) after a successful mutation:
# modules/orders/orders/endpoints/views.py
from fastapi.responses import RedirectResponse
@router.post("")
async def create(
data: OrderCreate, inertia: InertiaDep, service: OrderServiceDep
):
order = await service.create(data)
return RedirectResponse(f"/orders/{order.id}", status_code=303)Inertia's client follows the redirect and renders the new page.
Toasts
There is no flash shared prop. For "created successfully" toasts, fire sonner's toast directly from the page's request callback:
import { toast } from "sonner";
router.post("/api/orders", data, {
onSuccess: () => toast.success("Order created"),
});Server-side validation errors arrive on the page's errors prop (fastapi-inertia's use_flash_errors, enabled in the Inertia config).
What the shared props look like
Every Inertia response includes:
{
auth: {
user: { id, name, email, roles } | null,
isAuthenticated: boolean,
permissions: string[],
},
menus: {
sidebar: MenuItem[],
adminSidebar: MenuItem[],
navbar: MenuItem[],
userDropdown: MenuItem[],
},
i18n: {
locale: string,
supportedLocales: string[],
messages: Record<string, string> | null, // flat key → value; null on unchanged-locale XHR partials
},
}Accessed from any page via usePage().props.auth etc. (the exported SharedProps type from @simple-module-py/ui covers auth + menus); translations come from useT() in @simple-module-py/i18n. See Shared props & layout.
Frontend runtime setup
host/client_app/main.tsx imports ./styles.css and ./app; app.tsx bootstraps Inertia:
import { createInertiaApp } from "@inertiajs/react";
import { resolvePage } from "./pages";
createInertiaApp({
resolve: (name) => resolvePage(name),
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />);
},
});resolvePage lives in host/client_app/pages.ts (hand-written). It builds a page-key → loader map from moduleGlobs (the import.meta.glob calls in the generated modules.generated.ts) plus the host's own ./pages/**/*.tsx glob, mapping a page key (e.g. "Orders/Browse") to a dynamic import of the matching .tsx file.
CSRF
There is no explicit CSRF token middleware. Protection comes from SameSite=Lax on the session cookie:
- Browsers don't attach the session cookie to cross-site POST/PUT/DELETE.
- A forged form-submit from another origin arrives without authentication, so it's 401/403.
- Same-site form submissions work normally.
This is sufficient because Inertia mutations go through fetch, which honors SameSite. Raw fetch() calls in page code don't need a CSRF header.