Inertia
Inertia is the bridge between AdonisJS routes and React pages: controllers call inertia.render('<name>', { props }), the client resolves that name to a .tsx file inside a module's ui/pages/. Shared props flow globally through an Inertia middleware. Forms use @inertiajs/react's useForm; URLs come from Tuyau's urlFor('route.name', params?) for type safety. Modals are Inertia responses too — a controller helper renders one page over another. Every provider (theme, i18n, tooltip, Tuyau, modal stack) is wired at the root client entry.
Rules
- Page location:
app/<mod>/ui/pages/<subpath>/<page>.tsx. Controllers reference the page by the path minus.tsxand everything before<mod>/:inertia.render('users/index')→app/users/ui/pages/index.tsx,inertia.render('users/create')→app/users/ui/pages/create.tsx. First segment = module. - Shell import: every page imports its layout shell explicitly ([[layout-shells]]). No runtime chooser.
- Client entry mounts the global provider tree — theme, i18n, tooltip, Tuyau, modal stack. Don't reorder without reason; theme + i18n need to be outermost so shells see them.
- Shared props: added by the Inertia middleware via
ctx.inertia.always(...). Available on the frontend via a typedusePageProps<{...}>()hook. Per-request data (page-specific) goes in the controller'sinertia.render(...)call, not in the middleware. - Form: default is
useForm(initial)from@inertiajs/react+ a plain<form onSubmit>. Callpost(urlFor('route.name'), { onSuccess, onFinish, preserveScroll }). A local<Form>wrapper is provided that adds aFormErrorsContextso nested fields can pull errors from context — use it when the form is deep enough that field components want the errors without prop-drilling. - URLs: never hardcode. Use
urlFor('route.name', { param: value })from Tuyau. Route names come fromrouter.get(...).as('route.name')in the module'sroutes.ts. - Modals: controllers return the modal helper (
modal(inertia, 'page', props, { route: 'backdrop.route' })). The modal page wraps content in the modal component and receives acloserender prop. - Navigation between pages:
router.visit(url);router.reload({ only: ['propName'], async: true })for partial reloads;router.get/post/put/delete(url, data, options)for programmatic form submits. - Types: server-generated data types come from an ace codegen; the client alias makes them available across pages. Don't duplicate the type in the page —
import typefrom the codegen path.
Repo refs
- Page resolver:
app/core/ui/app/app.tsx. Shared props (share()):app/core/middleware/inertia_middleware.ts. - Modal helper:
app/core/inertia/modal.ts. Form:app/users/ui/components/profile_form.tsx. Page + typed props:app/users/ui/pages/index.tsx.
Doc refs
- AdonisJS Inertia — https://docs.adonisjs.com/guides/views-and-templates/inertia
- Inertia.js React — https://inertiajs.com/client-side-setup
useForm— https://inertiajs.com/forms- Tuyau (route-safe URLs) — https://tuyau.julr.dev/docs
Workflow
Add a page
- Route + controller method — see [[crud]] for the full CRUD flow, [[routes]] for the route shape. For a one-off page:
router.get('/dashboard', [DashboardController, 'show']).middleware(middleware.auth()).as('dashboard.show'). - Controller returns
inertia.render('<mod>/<page>', { props }). Props must be JSON-serializable — transform Lucid models with a Transformer variant (see [[crud]]). - Page file at
app/<mod>/ui/pages/<page>.tsx. Import the shell ([[layout-shells]]):type PageProps = InertiaProps<{ profile: Data.Users.User.Variants['forProfile'] }> export default function DashboardPage({ profile }: PageProps) { return ( <AuthenticatedLayout breadcrumbs={[{ label: 'Dashboard' }]}> {/* body */} </AuthenticatedLayout> ) }
Read shared props
const { user, csrf, unseenNotifications } = usePageProps<{
user?: Data.Users.User.Variants['forSharedProps']
csrf?: string
unseenNotifications?: number
}>()
Prefer typed access via usePageProps<T>(). For anonymous access to the raw page object, import { usePage } from '@inertiajs/react'.
Write a form
const { data, setData, errors, post, processing, reset } = useForm({ fullName: '', ... })
<form
=> {
e.preventDefault()
post(urlFor('users.store'), {
preserveScroll: true,
onSuccess: () => { close(); toast('Saved') },
onFinish: () => reset('password', 'passwordConfirmation'),
})
}}
>
<Field name="fullName" label="Full name" />
{/* ... */}
</form>
For deep forms where nested Field components want errors via context instead of props, use the <Form> wrapper that adds FormErrorsContext.
Modals
Controller side:
public async create({ bouncer, inertia }: HttpContext) {
await bouncer.with(EntityPolicy).authorize('create')
return modal(inertia, 'entities/create', {}, { route: 'entities.index' })
}
Modal page:
return (
<AppModal maxWidth="md">
{({ close }) => (
<>{/* form + Save calls close() on success */}</>
)}
</AppModal>
)
The { route: 'entities.index' } backdrop tells the modal system what to render underneath.
Partial reload
To refresh just some shared props without navigating:
router.reload({ only: ['unseenNotifications'], async: true })
Good for badges and counters that mutate independently of the current page.
Anti-patterns
- ❌ Hardcoding URLs (
/users/${id}) — useurlFor('users.edit', { id }). - ❌ Sending Lucid models to
inertia.render(...)— run through a Transformer variant ([[crud]]). - ❌ Adding page-only providers at the page level — providers belong at the root client entry.
- ❌ Sharing per-page data via the middleware's
share()— that inflates every response. Per-request data goes in the controller'sinertia.render(...). - ❌ Using
<a href>for internal navigation — use<Link>from@adonisjs/inertia/reactorrouter.visit. - ❌ Forgetting
preserveScroll: truewhen a form submit stays on the same page — the browser jumps back to the top otherwise. - ❌ Reading
page.propsuntyped — useusePageProps<T>(). - ❌ Duplicating a query's return type in the page —
import typefrom the query file (see [[queries]]).
Related skills
[[routes]] · [[crud]] · [[queries]] · [[layout-shells]] · [[i18n]] · [[actions-events]] · [[testing]]