Skip to content

Writing an Inertia page: render, shared props, deferred work, redirects, and handlers that only gather data.

Write the component:

views/pages/Reports.tsx
import { Head } from '@inertiajs/react'
export default function Reports({ rows }: { rows: Row[] }) {
return (
<>
<Head title="Reports" />
<ul>{rows.map(r => <li key={r.id}>{r.name}</li>)}</ul>
</>
)
}

Write the handler:

routes/web.py
from sillo_inertia import render
async def reports(request: Request, response: Response):
return await render("Reports", {"rows": await Report.all().values()})

Register it. Pages are declared as Route objects, with exclude_from_schema because they are not an API:

Route("/reports", handler=web.reports, methods=["GET"],
name="reports", exclude_from_schema=True)

No build step, no route file to regenerate, and no client-side router to keep in step.

render, redirect, back and location are importable on their own. They find the adapter through the middleware handling the request, so a routes module builds Inertia responses without importing the module that owns the application, and so without the circular import that would otherwise cause.

from sillo_inertia import render
async def home(request: Request, response: Response):
return await render("Home", {"name": "Sillo"})

Outside a request (a background job, a test calling a handler directly) pass one explicitly:

await inertia.render("Home", props, request=request)

With neither, render raises OutsideRequestError and names the three ways to end up there.

A prop can be a value, a callable, or a coroutine function. Callables that want the request take one parameter; those that do not, take none.

{
"count": 5, # a value
"total": lambda: Order.count(), # called per request
"mine": lambda request: request.user.orders, # given the request
"stats": fetch_stats, # async, awaited
}

Anything a prop returns is serialised to JSON and handed to the browser, so a model instance is the wrong thing to put in one. Return the fields you mean to publish:

{"user": {"id": user.id, "email": user.email}} # explicit
{"user": user} # ships every column

Listing them by hand is what stops a column added later (a password hash, an internal note) from quietly starting to reach the client.

Props every page receives are registered once, at startup:

inertia.share(
app_name=config.app_name,
auth=lambda request: {"user": current_user(request)},
)

They are registered once but resolved per request: a callable prop is called each time a page renders, so auth reflects whoever is making this request rather than whoever was making the one during startup.

A page’s own props win on a name clash.

When a handler does nothing but collect props, @inertia.page takes the rest:

@app.get("/users/{user_id}")
@inertia.page("Users/Show")
async def show(user_id):
return {"user": await User.get(id=user_id)}

The function declares only what it uses. Ask for request or response by name and you get them; leave them out and they are not passed. Path parameters, injected dependencies and validated bodies all arrive as they would on any handler.

Returning a response instead of a mapping sends that response untouched, so a handler can still redirect out of a page:

@app.post("/users")
@inertia.page("Users/Create")
async def create(request: Request):
await User.create(**await request.json())
return inertia.redirect("/users")
inertia.redirect("/dashboard") # 303 after POST/PUT/PATCH, 302 after GET
inertia.back() # to the Referer
inertia.back(fallback="/posts") # ...when there is no Referer

The 303 is not decoration. On a 302 the browser repeats the POST against the new URL, so a redirect after a successful create makes a second record.

To send someone out of Inertia entirely (an external URL, a hosted checkout) use location, which asks the client for a full browser visit rather than an XHR one:

inertia.location("https://billing.example.com/checkout")

Check and redirect. Do not reach for auth= on the route:

async def dashboard(request: Request, response: Response):
if not request.user.is_authenticated:
return redirect("/login")
return await render("Dashboard", {...})

auth= answers 401, which is right for a JSON API and wrong here: Inertia’s client surfaces a 401 as an unhandled error modal rather than a login screen. What the guard should do is application-specific, which is why it is written out rather than delegated.