Diff v3 → v4
v3: bot legacy · 2026-08-13T07:10:22Z
v4: bot legacy · 2026-08-19T06:28:12Z
# React Production Playbook (Inertia + Rails) > **When to use it:** building or reviewing a React frontend on an Inertia Rails > backend. Companion to the [Rails Production Playbook](https://wiki.joexbayer.dev/wiki/rails-production-playbook) > — that one covers the server; this one covers everything Inertia hands to React, > and marks which playbook items Inertia already solved. Assumes `@inertiajs/react` > 3.x / `inertia_rails` 3.x, React 19.2.x — note the two version lines move > independently, so check the adapter, not the gem, before relying on anything > marked *(Inertia 3)*. Default posture: *the server decides, the view renders, > one transport per datum.* **The organizing idea: the entire React tree is `app/views/`.** Inertia kept routing, controllers, services, models, authorization and presentation in Ruby. What it handed to React is the view layer. Every structural question therefore has a Rails answer already, and most of the SPA architecture canon — client routing, a fetch layer, a cache, an invalidation map — is not simplified here, it is *deleted*. Do not port it back in out of habit. The exception is large datasets, which get a real JSON API. That seam is the only genuinely new thing in this document; the rest is Rails discipline in TSX. --- ## Architecture & code design ### The spine Sandi Metz' rules now apply **literally, in Ruby**, all the way to the view. Rule 4 — *"a controller instantiates one object; a view knows one instance variable"* — becomes enforceable end-to-end for the first time: | Rails | Inertia React | | --- | --- | | Controller | **the Rails controller** | | Service object | **Ruby PORO** | | Presenter | **Ruby page presenter + serializer** | | Model | ActiveRecord | | View (ERB) | **page component** `pages/articles/show.tsx` | | Partial | feature component | | Helper | `lib/` | | Form object | Rails form object + `useForm` | ```ruby class ArticlesController < ApplicationController def show article = Article.includes(:author, :tags).friendly.find(params[:id]) authorize article render inertia: "articles/show", props: ArticleShowPage.new(article, current_user).to_props end end ``` One object per action. `ArticleShowPage` owns the whole prop shape — the same extraction you'd do the moment a controller action grew a second `@ivar`. - **Use when:** always. If an action assembles props inline and it isn't a one-liner, extract the page presenter. ### Project structure ``` app/ ├─ controllers/articles_controller.rb ├─ presenters/article_show_page.rb # owns the prop shape ├─ serializers/article_serializer.rb # Alba — explicit, shared with the API ├─ policies/article_policy.rb # feeds the `can:` props └─ frontend/ ├─ entrypoints/{application,ssr}.tsx ├─ pages/ # ≈ app/views/ — one folder per controller │ ├─ articles/ # the slice IS the controller's view folder │ │ ├─ index.tsx # ≈ app/views/articles/index.html.erb │ │ ├─ show.tsx │ │ ├─ new.tsx │ │ ├─ shared.ts # public surface of the slice (NOT index.ts) │ │ ├─ ui/ # the partials │ │ ├─ model/ # view logic Ruby *can't* do │ │ ├─ hooks/ # ephemeral UI state only │ │ └─ api/ # ONLY in slices with a tier-3 dataset │ └─ errors/error.tsx ├─ layouts/{app,auth}-layout.tsx # ≈ app/views/layouts/ ├─ components/ui/ # ≈ app/views/shared/ — shadcn ├─ lib/ # ≈ app/helpers/ + lib/ └─ types/serializers.d.ts # generated by Typelizer. Never hand-edited. ``` There is no `features/` directory. A domain slice and a controller's view folder are the same thing, so they are the same folder: `pages/articles/` holds the pages *and* their partials, exactly as `app/views/articles/` holds templates and `_partials`. A slice that owns no route doesn't exist — it's a controller you haven't written yet, or it belongs in `components/ui/`. Top-level files in the folder are actions; subdirectories are implementation. That single rule is the whole layout. Most slices have no `api/`. That asymmetry is informative: it shows at a glance which domains carry a real dataset. **Two mechanical consequences of colocating — fix both on day one.** **1. The resolver glob is now a bundle boundary.** Inertia's resolver globs the page tree, so once implementation lives under `pages/`, every `.tsx` in there becomes an addressable page name — and under an eager glob, an unconditional import into the client bundle. Exclude the implementation dirs and the tests: ```ts // entrypoints/application.tsx const pages = import.meta.glob<{ default: ComponentType }>( [ '../pages/**/*.tsx', '!../pages/**/{ui,model,hooks,api}/**', '!../pages/**/*.{test,stories}.tsx', ], { eager: true }, // pages resolve synchronously — no dynamic import between click and screen ); resolve: (name: string) => { const page = pages[`../pages/${name}.tsx`]; if (!page) { const known = Object.keys(pages).map((p) => p.replace('../pages/', '').replace(/\.tsx$/, '')); throw new Error( `Inertia page "${name}" not found — expected pages/${name}.tsx. Known: ${known.join(', ')}`, ); } return page; }, ``` Two failures this prevents, both seen in the wild: - Without the negative patterns, `articles/ui/article-header` is addressable as a page — a partial rendered as a full response with none of its props — and a colocated `Search.test.tsx` is eagerly imported, shipping Testing Library and fixtures to production. **Check this one first: it's silent, and it predates colocation.** Any project that colocates tests under `pages/` already has it. - Without the explicit `throw`, a missing or renamed page resolves to `undefined` and dies several frames deep inside React — a blank screen, not an error. The controller's `render inertia:` string and the file path are exactly the two things worth printing when they disagree. Keep the `known` list: it turns a five-minute bisect into a glance. (If you support both `.jsx` and `.tsx` during a TS migration, widen the glob **and** the lookup key — widening only the glob makes every page resolve and then miss.) **2. The slice barrel cannot be `index.ts`** — `pages/articles/index.tsx` is already the `index` action. Name it `shared.ts` and let the collision stay impossible rather than resolution-order-dependent. ### When do the slice subdirectories earn their keep? Not immediately. Below roughly a dozen pages, a flat `components/` next to a flat `pages/` is the right amount of structure, and the `ui/ model/ hooks/ api/` split inside each slice is ceremony — you can see the whole tree at once, so nothing is lost by not naming it. Promote a slice to subdirectories when its partials outnumber its actions, or the first `api/` appears. Promote per slice, not repo-wide: one domain carrying a tier-3 dataset while the rest stay flat is the correct end state, not an inconsistency to tidy up. ### Page components are ERB templates ```tsx export default function ArticlesShow({ article, comments, can }: ArticleShowPageProps) { return ( <> <ArticleHeader article={article} canEdit={can.edit} /> <ArticleBody article={article} /> <CommentSection comments={comments} articleSlug={article.slug} canComment={can.comment} /> </> ); } ArticlesShow.layout = (page: React.ReactNode) => <AppLayout>{page}</AppLayout>; ``` Props in, partials composed, layout declared. The `.layout` assignment is `layout "application"`, and it's what keeps the layout *persistent* across visits so sidebar scroll and open menus survive navigation. - **If a page component needs a comment to explain it, the logic belongs in the presenter.** - Page grows past a screenful → extract a partial into `pages/<controller>/ui/`. - Props grow unwieldy → that's a presenter problem. Split the presenter or defer the expensive branch. The fix lives in Ruby. ### Layer rules within a slice ``` ui/ ──→ hooks/ ──→ api/ ──→ lib/http └────────┴────────→ model/ (pure — imports nothing but lib/) ``` The action files at the top of the folder sit above all of it: a page imports its own `ui/`, nothing imports a page. Enforce with `eslint-plugin-boundaries`: `ui` may not import `api`; `model` may import only `lib`; cross-slice traffic goes through `pages/*/shared.ts` only — never a deep import into another controller's `ui/`. This is the Packwerk `public/` boundary, and it's the difference between colocation as an aspiration and as a constraint. Colocation makes the common violation louder, which is the point: a partial that two controllers both want is now visibly in the wrong folder, and the fix is `components/ui/` or a shared parent slice — not a deep import. ### Where does it go? | Question | Answer | | --- | --- | | Renders one controller action? | `pages/<controller>/<action>.tsx` | | Reused across actions of one controller? | `pages/<controller>/ui/` | | Reused across controllers? | `components/ui/` | | Wraps pages? | `layouts/` | | Derives a value from props? | **the Ruby presenter** | | Needs the browser to compute it? | `pages/<controller>/model/` | | Ephemeral UI state (open/closed, hover)? | `pages/<controller>/hooks/` | | Fetches page data? | **nowhere. It's a prop.** | | Fetches a large collection? | `pages/<controller>/api/` — see tiers below | + | A dialog someone should be able to link to? | **a route** — `pages/<controller>/new.tsx`, rendered in a `<Modal>` | + | A confirm, tooltip or context menu? | state, not a route — `pages/<controller>/ui/` + `hooks/` | `model/` is deliberately narrow: relative timestamps, client-side filtering of an already-loaded list, layout measurement. Currency formatting, status labels and permission derivation all belong in Ruby. If `model/` is growing, presentation is leaking out of the presenter. ### Shared props are `ApplicationController` ```ruby class ApplicationController < ActionController::Base inertia_share do { auth: { user: current_user && UserSerializer.new(current_user).to_h }, flash: { notice: flash.notice, alert: flash.alert }, traceId: request.request_id, } end end ``` ```ts // lib/use-page-props.ts — type it once, never call usePage() raw again export const useAuth = () => usePage<SharedProps>().props.auth; export const useFlash = () => usePage<SharedProps>().props.flash; ``` Raw `usePage()` in a component is the equivalent of reaching into `session` from an ERB template. --- ## Data & transport ### Three tiers — one transport per datum This is the central operational decision in an Inertia app. Props are **page render payload**, not a resource representation: they are re-serialized on every visit and retained in history state for back/forward. A 5,000-row table therefore costs a serialization per visit and a copy per history entry. Deferred props delay that cost; they don't remove it. There is also no cross-page cache. | Tier | Transport | When | | --- | --- | --- | | **1 — small** | Inertia props | session, auth, flash, permissions, the record being viewed, form option lists, anything under a few hundred rows | | **2 — large, page-bound** | `InertiaRails.defer` + merge props + `WhenVisible` | big list that loads once, scrolls, and never refreshes independently of the page | | **3 — large, interactive** | **JSON API + TanStack Query** | typeahead, polled/live tables, cross-page reuse, server-side filter/sort that shouldn't re-run the controller, virtualized windows, exports | - **Tier 3 is earned by needing a cache with its own lifecycle, not by row count.** Reach for tier 2 first — it's a controller change, not a new surface. - **Use when:** decide per dataset, write it down (below), don't drift. ### The manifest Three lines per slice, in its `shared.ts` or README. This is the artifact that keeps the seam honest six months in: ``` articles/ article (record) → props articles (collection) → API [first page seeded from props] stats → props comments/ comments (collection) → API commentCount → API ← lives with the list, not in props ``` The failure mode of a hybrid is never complexity — it's `commentCount` arriving via props while the list arrives via Query, and the two disagreeing after a write. ### Partial reloads replace invalidation ```ts router.reload({ only: ['comments', 'stats'] }); ``` `invalidateQueries`, keyed by prop name, declared at the call site, with the server deciding freshness. Dot-notation targets nested props. ### The tier-3 API — one backend, two renderers ``` app/controllers/ ├─ articles_controller.rb # Inertia — page renders └─ api/articles_controller.rb # JSON — collections app/serializers/article_serializer.rb # ← shared app/policies/article_policy.rb # ← shared ``` ```ruby module Api class ArticlesController < Api::BaseController def index scope = policy_scope(Article).includes(:author, :tags) page = ArticlesQuery.new(scope, filter_params).cursor_page(params[:cursor]) render json: { items: ArticleSerializer.new(page.records, view: :summary).to_a, nextCursor: page.next_cursor, } end end end ``` Same query object, same serializer, same policy — only the renderer differs. `Api::BaseController` differs from `ApplicationController` in exactly three ways: it renders `problem+json`, it skips `inertia_share`, and it has its own rate limit. - **Cookie-authed, same-origin, CSRF-protected.** Internal surface, not a public API: no bearer tokens, no `/v1/`, no versioning — same session, same deploy. A public API is a *third* surface with its own contract and lifecycle. - **Cursor pagination**, not offset. Deep offsets on a large table are the thing that made you build tier 3 in the first place. ### Layering the client side of tier 3 `apiFetch` is the adapter; `useQuery` is the service. Never collapse them — a hook can't be called outside a render, and the same `apiFetch` must serve mutations, prefetches and exports. ```ts // lib/http.ts — the boundary. Knows HTTP. No React. export async function apiFetch<T>(path: string, schema: z.ZodType<T>, init?: RequestInit) { const res = await fetch(path, { ...init, credentials: 'same-origin', headers: { Accept: 'application/json', 'X-CSRF-Token': csrfToken(), 'X-Correlation-ID': correlationId(), ...init?.headers, }, }); if (res.status === 401) { router.visit('/login'); throw new SessionExpired(); } if (!res.ok) throw await parseProblem(res); return schema.parse(await res.json()); } ``` ```ts // pages/articles/api/endpoints.ts — repository export const fetchArticles = (params: ArticleQueryParams) => apiFetch(ArticlesApi.index.path(), ArticlePage, { query: params }); // pages/articles/api/queries.ts — query objects export const articleKeys = { all: () => ['articles'] as const, list: (f: Filters) => [...articleKeys.all(), 'list', f] as const, }; // pages/articles/hooks/use-article-list.ts — service. useQuery lives here. export function useArticleList(filters: Filters, seed?: Page<ArticleSummary>) { /* ... */ } ``` The 401 branch matters in a hybrid: a dead session on the API surface must hand control back to Inertia, not surface as an error toast. ### Bridging the seam Seed Query from props so first paint has no spinner and no duplicate request: ```ts initialData: seed && isDefaultFilters(filters) ? { pages: [seed], pageParams: [null] } : undefined, ``` The `isDefaultFilters` guard is load-bearing — seeding a filtered query with an unfiltered first page is a silent correctness bug. Crossing the boundary should be rare and explicit, one line in the success handler: ```ts form.post(path, { onSuccess: () => queryClient.invalidateQueries({ queryKey: commentKeys.list(slug) }) }); useMutation({ mutationFn: archive, onSuccess: () => router.reload({ only: ['stats'] }) }); ``` --- ## Errors — three channels, one shape Inertia never receives 422 responses. Validation errors are redirected back and flashed into the session, arriving as the `errors` prop; Inertia checks `page.props.errors` to decide whether `onError()` or `onSuccess()` fires. Do not force `problem+json` through page visits — Inertia's protocol already solved that. | Channel | Transport | Renders as | | --- | --- | --- | | Validation | session flash → `errors` prop | `form.errors` | | Exception on an Inertia visit | `rescue_from` → Inertia response | `pages/errors/error.tsx` | | Non-Inertia JSON (tier 3, `useHttp`, webhooks, agents) | **`problem+json`** | `parseProblem()` | ```ruby # app/errors/error_payload.rb — one shape, two renderings class ErrorPayload def to_problem_json = { type:, title:, status:, detail:, trace_id: } def to_inertia_props = { status:, title:, detail:, traceId: } end class ApplicationController < ActionController::Base rescue_from StandardError, with: :render_error private def render_error(exception) raise exception if Rails.env.local? status = ActionDispatch::ExceptionWrapper.new(nil, exception).status_code payload = ErrorPayload.new(exception, request) if request.headers["X-Inertia"] render inertia: "errors/error", props: payload.to_inertia_props, status: status else render json: payload.to_problem_json, status: status, content_type: "application/problem+json" end end end ``` Both carry `trace_id`, so the correlation-ID story survives: the error page shows it, the user quotes it, one grep spans click → controller → service → job. ```ts // lib/errors.ts — one client shape, whichever channel it arrived on export type AppError = { status: number; title: string; detail?: string; fields: Record<string, string[]>; traceId?: string; }; export const fromInertia = (errors, traceId): AppError => ... export const fromProblem = (body): AppError => ... ``` One `<ErrorSummary error={…} />` renders both. **Fix on day one: Rails and Inertia disagree about `errors` twice**, and the two halves have different fixes. This is the single most reliably re-solved-per-page problem in the stack, so solve it once, globally, in one file. *The value type.* Inertia types an error value as `string`; Rails `errors.to_hash` gives `string[]` per attribute, one message or many. Don't flatten it server-side with `to_sentence` — that discards per-message structure you may want to render as a list. Widen the client instead, at the extension point Inertia publishes for it: ```ts // lib/rails-errors.ts declare module '@inertiajs/core' { interface InertiaConfig { errorValueType: string[] } } ``` Module augmentation is global no matter which file declares it, so this fixes `useForm().errors`, the page-level `errors` prop and `setError` in one line. The same `InertiaConfig` interface types your flash payload (`flashDataType`) — declare them as the pair they are. *The key namespace.* This one has no global fix. Inertia keys errors by **data path** — form data `{ account: { email } }` yields `'account' | 'account.email'` — which is Laravel's convention, where the error key *is* the parameter path. Rails keys by the bare **attribute** (`email`). No generic argument describes both, and no reshaping of the form data reconciles them; it's a disagreement between two frameworks, not a bug in either. So assert the conversion once: ```ts export type RailsErrors<K extends string> = Partial<Record<K, string[]>> export const railsErrors = <K extends string>(errors: object) => errors as RailsErrors<K> ``` It must be a cast: `RailsErrors<K>` is a weak type, so TypeScript rejects a plain annotated assignment from `form.errors` with *"no properties in common"* — the two key namespaces genuinely share none. Each page then declares only its attribute list. **Enumerate every attribute the model can error on, not just the ones with an input.** A custom validation that adds to `:video_asset` when no asset field exists on the form renders "Correct the marked fields below" with nothing marked — a dead-end error state, and the reason this belongs on a checklist. --- ## Security ### Authorization Pundit in the controller; policies ride along as props. No route tree to guard — that's a real reduction in attack surface versus an SPA. ```ruby can: { comment: policy.comment?, edit: policy.edit? } availableActions: article.aasm.events(permitted: true).map(&:name) ``` The UI renders buttons from `availableActions`. The client never re-encodes transition rules, so the two state machines can't disagree. Client-side `can` checks are UX; the controller re-checks every action. ### Props are a security boundary Every instance variable becomes a prop, and a model prop serializes through `as_json` — every column, password digest included. **Explicit Alba serializers are not stylistic here.** Ban bare model props in review; a lint or a spec that asserts serializer usage is better. ### Config There are no secrets in a bundle. Two buckets: | Rails bucket | Frontend equivalent | | --- | --- | | Encrypted credentials | **does not exist** — anything secret stays server-side, which under Inertia is nearly everything | | `.yml` config | build-time `import.meta.env.VITE_*` — **world-readable** | | ENV | runtime values arrive as **shared props**, not as a `/config` endpoint | Inertia makes this easier than an SPA: runtime config is just a shared prop. CI-grep `VITE_*` for `*_SECRET|*_KEY|*_TOKEN` and fail the build. ### Supply chain `pnpm install --frozen-lockfile`, `pnpm audit --prod`, Semgrep, review gate on lockfile diffs. A real CSP — note that Inertia's dev error modal injects inline styles, so pass a nonce to `createInertiaApp()` (or enable the native `<dialog>` error modal via the `future.useDialogForErrorModal` default). ### RSC Not applicable — you aren't running it, which is the correct outcome. For context on why that matters: CVE-2025-55182 ("React2Shell", CVSS 10.0, Dec 2025) was an unauthenticated RCE in the RSC Flight protocol, near-100% exploitable against default configs, CISA KEV, exploited within days. Inertia sidesteps the entire category. Keep React itself patched regardless. --- ## Reliability & resilience ### What Inertia already gives you | Playbook item | Status | | --- | --- | | **Version skew / stale tabs** | ✅ built in — asset versioning forces a full reload on mismatch | | **Cache clear on logout** | ✅ built in — history encryption / `clearHistory` | | **Client-side cache leaks between tenants** | ✅ void for tiers 1–2 (no cache). Tier 3 needs the tenant in the query key | | **Optimistic updates + rollback** | ✅ built in (Inertia 3) | | **Double-submit** | ✅ `form.processing` | ### What is still yours - **Idempotency keys.** `form.processing` stops double-clicks, not network retries. For money-moving actions, mint `crypto.randomUUID()` once per intent, carry it as a hidden field or header, back it with a unique index. - **Retry policy (tier 3 only).** 5xx and 429 only, backoff with jitter, respect `Retry-After`, `AbortSignal.timeout()` on every request. - **Release stamping.** Inject semver + git SHA at build; expose in the footer, send as a header, set as the Sentry release. Mirrors [Kamal Auto-Versioning](https://wiki.joexbayer.dev/wiki/kamal-auto-version). - **SSR sidecar.** The one place Inertia costs an operational dependency. It fails gently — a dead sidecar degrades to client rendering, not a 500 — but it belongs in your deep `/health` check. - **Deferred prop failures.** `InertiaRails.defer(rescue: true)` omits the prop and reports via the Rails Error Reporter rather than failing the page. Use it for non-critical panels; make sure the component handles the absent prop. --- ## Observability ### Correlation IDs Easier than an SPA — one `inertia_share` block puts `traceId` on every page. 1. Rails mints or accepts it at the edge (`X-Correlation-ID`). 2. Shared prop carries it to the client. 3. Tier-3 requests echo it back in the header (`apiFetch` above). 4. `problem+json` and the Inertia error page both surface it to the user. 5. OTel browser SDK propagates `traceparent` so the browser span parents the Rails span — the trace starts at the click. ### The rest - **Logs:** structured, sampled, stripped from the production build. Never `console.log`. - **Metrics:** Web Vitals with **INP** as the headline. Inertia visits are not page loads — instrument visit duration explicitly via router events. Report at p75, never as a mean — [QA and QoE](https://wiki.joexbayer.dev/wiki/qa-and-qoe) covers why the average is the one number guaranteed to hide the regression. - **Errors:** Sentry with release, tenant, user, correlation ID. An ErrorBoundary per layout so one broken partial doesn't blank the page. - **Prop payload size** is a metric worth alerting on. It's the first symptom of a tier-1 dataset that should have become tier 2 or 3. --- ## Testing | Rails | Inertia React | | --- | --- | | `rspec` request specs | **`inertia_rails` matchers** — assert component name + prop shape. This is the highest-value test in the stack | | presenter unit specs | plain Ruby specs on the page presenter | | `factory_bot` + `faker` | unchanged — test data stays in Ruby | | view specs | Vitest + Testing Library, rendering the page component with fixture props | | `webmock` / `vcr` | MSW — **tier 3 only** | | `capybara` / Playwright | Playwright, critical paths only — via MCP the same run also yields QoE numbers ([QA and QoE Testing with Playwright MCP](https://wiki.joexbayer.dev/wiki/qa-and-qoe-testing-with-playwright-mcp)) | | `prosopite` (N+1) | unchanged, and *more* important — props are the render path | | `brakeman` / `bundler-audit` | plus `pnpm audit --prod`, Semgrep | Two rules: - **Test the contract at the Rails boundary.** A request spec asserting the component name and prop keys catches more than any frontend test, because that's where the two halves actually meet. - **Generate page-component fixtures from Typelizer types** so a serializer change breaks the frontend test, not production. --- ## User feedback - **Flash as a shared prop → one toast pipeline.** `sonner`, driven by a single `useFlash()` effect in the layout. Never per-page toast wiring. - **Errors are product.** `detail` says what to do next; the trace ID is visible and copyable. - **Empty and loading states designed, not defaulted.** With deferred props, `<Deferred>` fallbacks are a real design surface — note that in Inertia 3 the fallback no longer re-shows during partial reloads; use the `reloading` slot prop for an indicator that keeps existing content visible. --- + ## Overlays — menus, modals, tooltips + + Every app grows a layer of things that float above the page: a right-click menu + on a row, an edit dialog, a tooltip on a truncated cell. In an Inertia app that + layer has one question that decides everything else, and it is not a React + question: + + > **Is this overlay a piece of UI state, or is it a route?** + + Get it wrong and you either mint a bookmarkable URL for a confirm dialog, or you + build an edit form nobody can link to, share, or reach with the back button. + + ### The split + + | Overlay | Kind | Lives in | + | --- | --- | --- | + | Tooltip, hover card | **state** — transient, never linkable | `pages/<controller>/ui/` | + | Context menu | **state** — dismissed by any action | `pages/<controller>/ui/` | + | Confirm ("Delete this?") | **state** — a question, not a place | `components/ui/` | + | Create / edit dialog | **route** — bookmarkable, back button, new tab | `pages/<controller>/new.tsx` | + | Detail pane / slideover | **route** when it has its own content to load | `pages/<controller>/show.tsx` | + + - **Rule:** if someone could reasonably paste the URL to a colleague, it is a + route. If it is a question about something already on screen, it is state. + - **Rule:** overlay state is `hooks/` state — the same slot the layer rules + already reserve for ephemeral UI. It never becomes a prop and it never becomes + a store. + + ### Route modals — the server still decides + + Inertia has no nested routing, so "open `/users/5/edit` over the index" needs the + server to say what renders underneath. `inertia_rails` ships this: + + ```ruby + # app/controllers/users_controller.rb + def edit + user = policy_scope(User).find(params[:id]) + authorize user + + render inertia_modal: { user: UserSerializer.new(user).to_h }, + base_url: users_path # what renders BEHIND, on a direct hit or refresh + end + ``` + + ```tsx + // pages/users/edit.tsx — an ordinary page that happens to render as a modal + import { Modal } from '@inertiaui/modal-react'; + + export default function UsersEdit({ user }: UsersEditProps) { + return ( + <Modal> + <UserForm user={user} /> + </Modal> + ); + } + ``` + + ```tsx + // The trigger. `navigate` is NOT optional if you want a URL. + <ModalLink navigate href={UsersApi.edit.path({ id: user.id })}>Edit</ModalLink> + ``` + + **`navigate` is the gotcha.** Without it `ModalLink` opens the modal *without* + touching the address bar or history — a modal that looks route-driven, isn't, and + silently loses the back button, the shareable link and open-in-new-tab. If a modal + has a `base_url`, it should almost certainly have `navigate`. + + **Three failures that fall out of the persistent layout**, worth wiring once + rather than debugging per dialog: + + - **A modal survives navigation.** The layout persists across visits by design + (that is what keeps sidebar scroll), so overlay state not keyed to the page is + still mounted after `router.visit`. Close on navigate, or key the overlay to + `usePage().component`. + - **`useForm` state is sticky.** Reopening a dialog whose form was never `reset()` + shows the previous attempt's values and, worse, its `errors`. Reset on close, + not on open — on open is one render too late and the stale errors flash. + - **Focus does not come back.** Return focus to the trigger on close. Native + `<dialog>` and the Popover API do this for you; a hand-rolled portal does not. + + ### Context menus — you are replacing a platform affordance + + A right-click menu means calling `preventDefault()` on the browser's own menu. + That is a real trade, and it carries debts most implementations skip: + + ```tsx + export function RowMenu({ row, children }: RowMenuProps) { + const [at, setAt] = useState<{ x: number; y: number } | null>(null); + const open = (x: number, y: number) => setAt({ x, y }); + + return ( + <> + <tr + tabIndex={0} + onContextMenu={(e) => { e.preventDefault(); open(e.clientX, e.clientY); }} + // Keyboard parity — the ContextMenu key and Shift+F10 are how this opens + // without a mouse. Omit them and the actions are simply unreachable. + onKeyDown={(e) => { + if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) { + e.preventDefault(); + const r = e.currentTarget.getBoundingClientRect(); + open(r.left, r.bottom); + } + }} + > + {children} + </tr> + {at && <Menu at={at} row={row} onClose={() => setAt(null)} />} + </> + ); + } + ``` + + - **Rule: never the only path to an action.** A right-click-only "Delete" is + unreachable on touch, unreachable by keyboard and undiscoverable by everyone + else. A context menu is a *shortcut* to actions that also exist in a row + overflow menu or a toolbar. An action that lives only here does not exist. + - **Positioning is the one overlay CSS cannot help with.** Anchor positioning + needs an anchor element; a context menu is anchored to a *point*. Either do the + viewport math yourself (flip when `x + width > innerWidth`) or hand it to + shadcn's `context-menu` (Radix underneath), which already has. + - **Dismissal is more than Escape:** outside click, scroll, resize, route change, + and the next context-menu event elsewhere. + - **Use when:** dense, row-oriented surfaces where a per-row overflow button would + be visual noise. Not on a marketing page, not on a form. + + ### Tooltips — the platform does this now + + CSS anchor positioning plus the Popover API is Baseline 2026 (~91% of traffic; + Chrome 125+, Firefox 132+, Safari 18.2+ — `@position-try` flipping wants Safari + 18.4+). For a plain tooltip that is less code than the library it replaces, and it + renders in the top layer: no `z-index` ladder, no clipping by an ancestor's + `overflow: hidden`. + + ```css + .tooltip-trigger { anchor-name: --tt; } + + .tooltip { + position: absolute; + position-anchor: --tt; + position-area: block-start center; /* above, centred */ + margin-block-end: 0.5rem; + position-try-fallbacks: --flip-below; /* …unless there is no room */ + } + + @position-try --flip-below { position-area: block-end center; margin-block: 0.5rem 0; } + ``` + + - **Rule: a tooltip may not carry information the task requires.** It is + unreachable on touch and frequently skipped by assistive tech. If the user needs + it to finish the action, it belongs in the label, the helper text or a + `<details>` — not a tooltip. + - **Hover *and* focus.** A tooltip that only opens on `mouseenter` does not exist + for keyboard users. Wire `focus`/`blur` alongside `pointerenter`/`pointerleave`. + - **`aria-describedby`** pointing at the tooltip's id — that is what makes it a + tooltip rather than a floating div. + - **Delay on open, not on close.** ~400 ms before showing kills the flicker as a + pointer crosses a toolbar; closing should be immediate. + - **`popover="hint"`** is the semantically correct state (it does not close other + popovers) but is newer than the rest — check support before relying on it. + `manual` plus your own dismissal is the conservative choice today. + + ### What to build vs what to install + + | Need | Reach for | Library only when | + | --- | --- | --- | + | Tooltip | Popover API + anchor positioning | rich hover card with interactive content | + | Dropdown / select | `<select>`, or Popover + anchor | multi-select, async search, virtualised options | + | Confirm | native `<dialog>` | never — this is twenty lines | + | Modal (route) | `@inertiaui/modal-react` + `inertia_modal:` | always for route modals; don't hand-roll history | + | Context menu | shadcn `context-menu` | almost always — point-anchoring and dismissal are fiddly | + | Toast | `sonner`, one `useFlash()` effect in the layout | never per page | + + - **Rule:** overlays in the browser's top layer (native `<dialog>`, `popover`) + don't participate in stacking context, so they cannot be clipped by an + ancestor's `overflow` and need no `z-index` scale. A portal-based library + re-introduces both problems — that is a reason to prefer the platform, not a + detail. + - **CSP:** a JS positioning library that writes inline styles needs the nonce you + already pass to `createInertiaApp()`. The CSS-native path needs nothing. + + ### The component library underneath + + Most of the primitives above ship in **shadcn/ui**, and the Inertia Rails cookbook + has the canonical setup — + [Integrating shadcn/ui](https://inertia-rails.dev/cookbook/integrating-shadcn-ui). + Two things about it matter here specifically: + + **It lands where the layout already says it should.** `npx shadcn@latest init` + installs into `app/frontend/components/ui/` — exactly the `components/ui/` slot in + the project structure above, the cross-controller shelf. The conventions already + agree; nothing to reconcile. + + **The two tsconfigs need different values, and this is the step people miss:** + + ```jsonc + // tsconfig.json — baseUrl is the frontend root + { "baseUrl": "./app/frontend", "paths": { "@/*": ["./*"] } } + + // tsconfig.app.json — baseUrl is the repo root + { "baseUrl": ".", "paths": { "@/*": ["./app/frontend/*"] } } + ``` + + The same `@/*` alias resolving to the same directory, written twice because the + two files sit at different roots. Get one wrong and the CLI writes components that + typecheck under one config and not the other — and if the root `tsconfig.json` is + solution-style (`"files": []` + project references), a bare `tsc --noEmit` + compiles **nothing** and exits 0, so the breakage stays invisible until a real + build. Typecheck with `tsc --build`. + + - **Use when:** you want the primitives without owning their accessibility. You + still own composition — shadcn copies source into your repo rather than adding a + dependency, so a `context-menu.tsx` nobody read is code you now maintain. + - Read the rest of the [Inertia Rails cookbook](https://inertia-rails.dev/cookbook/inertia-modal) + before building overlay infrastructure: modals, slideovers, wizards and filtered + search are already solved there. + + --- ## Async & long-running work - **Background jobs:** mutation returns a job id; poll with Inertia's polling or `router.reload({ only: ['job'] })` until terminal. The job id is the resume token — refresh or a new tab rejoins. - **Streaming / agentic:** SSE with buffer + throttled flush, `AbortController` wired to unmount and to a visible Stop button. Treat the LLM call as a flaky dependency; keep the surrounding job idempotent. --- ## Packages **Ruby** | Gem | Why | | --- | --- | | `inertia_rails` | the adapter (3.x) | | `vite_rails` | asset pipeline | | `alba` + `alba-inertia` | explicit prop shapes — a security boundary, not a style choice | | `typelizer` | TS types generated from serializers — the contract layer | | `js_from_routes` | typed path helpers in TS; a renamed action becomes a compile error | | `pundit` / `action_policy` | feeds `can:` props | | `oj` | props are the render hot path | **JS** | Package | Why | | --- | --- | | `@inertiajs/react` | the adapter | | `@inertiajs/vite` | automatic page resolution, code splitting, SSR setup | | `vite-plugin-ruby` | pairs with `vite_rails` | | `@tanstack/react-query` | **tier 3 only** | | `@tanstack/react-table` + `react-virtual` | the tables that justified tier 3 | | `zod` | parse at the tier-3 boundary | | `@js-from-routes/client` | `formatUrl` + generated helpers | + | `@inertiaui/modal-react` | route modals — pairs with `render inertia_modal:` | + | `shadcn/ui` (copied in, not a dep) | the primitives you don't want to own the a11y of | + | `sonner` | one toast pipeline, driven by `useFlash()` | Configure `js_from_routes` to emit **paths only** — let `apiFetch` own the request, or you have two boundaries. **Don't install:** `react-router` / TanStack Router (Inertia owns routing), `axios` (v3 ships its own XHR client), `react-hook-form` (`useForm` owns the Inertia protocol; RHF wants to own submission and you'd lose `processing`, `progress` and error wiring), anything Redux-shaped. **Write yourself** — the entire custom surface: ``` lib/ ├─ http.ts # apiFetch — the tier-3 boundary ├─ errors.ts # parseProblem + fromInertia → one AppError ├─ use-page-props.ts # typed useAuth() / useFlash() ├─ rails-errors.ts # errorValueType + flashDataType augmentation, railsErrors<K>() ├─ query-client.ts # retry policy, staleTime defaults └─ correlation.ts # mint + read the correlation ID ``` **Tooling:** `bin/setup` (clone → running) and `bin/dev` running Rails, Vite and Typelizer's watcher from one `Procfile.dev`. Type generation is a build step that rots silently if it's manual. Use `inertia_rails` generators for pages and a `plop` generator for slices — new features scaffolded, not assembled. --- ## Quick reference | Concern | Reach for | Use when | | --- | --- | --- | | Prop shape | Ruby page presenter, one per action | always | | Serialization | Alba, explicit views | always — bare model props leak columns | | Types | Typelizer from serializers | always | | Paths | `js_from_routes`, paths only | any URL built in TS | | Small data | Inertia props | under a few hundred rows | | Large, page-bound | `defer` + merge + `WhenVisible` | loads once, scrolls | | Large, interactive | JSON API + Query | needs its own cache lifecycle | | Refresh | `router.reload({ only: [...] })` | the invalidation analogue | | Forms | `useForm` + redirect-with-errors | always | | Validation errors | `errors` prop + `errorValueType` / `railsErrors<K>` | always | | API errors | `problem+json` + `parseProblem()` | tier 3 and external consumers | | Exceptions | `rescue_from` → `pages/errors/error.tsx` | production | | Authorization | Pundit → `can:` / `availableActions` props | always | | Idempotency | UUID per intent + unique index | money-moving actions | | Correlation ID | `inertia_share` + `X-Correlation-ID` | every operation | | Version skew | ✅ Inertia asset versioning | free | | Logout | ✅ history encryption / `clearHistory` | free | | Boundaries | `eslint-plugin-boundaries` | more than one contributor | | Testing | `inertia_rails` matchers first | always | | Observability | OTel, Web Vitals (INP), Sentry, prop-size metric | before you need it | + | Modal (route) | `render inertia_modal:` + `base_url:` + `ModalLink navigate` | bookmarkable / back-button dialogs | + | Modal (local) | native `<dialog>` | confirms and transient questions | + | Tooltip / popover | Popover API + CSS anchor positioning | Baseline 2026; library only for rich hover cards | + | Context menu | shadcn `context-menu` + keyboard parity | dense row surfaces; never the only path | + | Components | shadcn/ui into `components/ui/` ([cookbook](https://inertia-rails.dev/cookbook/integrating-shadcn-ui)) | always | ## Review checklist - [ ] Controller action assembling props inline? → page presenter. - [ ] A bare model passed as a prop? → serializer. This is a leak, not a nit. - [ ] Page component containing logic that Ruby could have done? - [ ] Any `fetch` outside `lib/http.ts`? - [ ] Any `useQuery` outside `pages/*/hooks/`? - [ ] A dataset transported two ways? → check the manifest. - [ ] Tier-1 props carrying a large collection? → defer, or promote to tier 3. - [ ] Query key missing the tenant (tier 3)? - [ ] Client re-encoding backend transition rules? → `availableActions`. - [ ] Raw `usePage()` in a component? → typed wrapper. - [ ] `ui/` importing `api/`, or a cross-slice deep import? - [ ] New top-level `.tsx` in a slice that isn't a controller action? - [ ] Resolver glob still excluding `{ui,model,hooks,api}` **and `*.test.tsx`**? - [ ] Resolver still throws (with the known-page list) on a miss? - [ ] A page casting `form.errors` inline instead of `railsErrors<K>`? - [ ] `RailsErrors<K>` missing an attribute only a custom validation writes to? - [ ] Errors handled anywhere but `useForm` / `parseProblem()`? - [ ] Rails error arrays passed straight to `useForm`? - [ ] Idempotency key regenerated per retry? - [ ] Anything secret-shaped in `VITE_*`? - [ ] Bundle carries no version/SHA? - [ ] Request spec asserting component name + prop shape? + - [ ] A route modal without `navigate` (no URL, no back button)? + - [ ] `useForm` reset on close, so reopening shows no stale errors? + - [ ] Context-menu actions reachable by keyboard and touch, not right-click only? + - [ ] A tooltip carrying information the task actually requires? + - [ ] Overlay state keyed to the page, or left mounted across a visit? - [ ] Any rule broken without a one-line justification? → fix or justify. ## Anti-patterns (reject in review) - Porting SPA architecture back in: client router, fetch layer, cache, for tier-1 data. - A `features/` directory parallel to `pages/` — two homes for one controller's view layer, and a standing argument about which one a file belongs in. - Bare model props (`render inertia:` with an AR object). - Business rules, permission logic or money arithmetic in TSX. - A JSON API endpoint built for a dataset that `defer` would have handled. - The same datum available as both a prop and a Query resource. - `problem+json` forced through Inertia page visits. - React Hook Form fighting `useForm` over submission. - Un-virtualized tier-3 tables. - A public-API surface grown accidentally out of the internal one — no versioning, no contract, external consumers. - Typelizer output edited by hand. + - A right-click menu as the ONLY route to an action — unreachable on touch and by + keyboard, and invisible to everyone who never thinks to try it. + - An edit dialog with no URL: unshareable, un-bookmarkable, and it eats the back + button. If it has a `base_url` it wants `navigate`. + - A hand-rolled portal with a `z-index` ladder where `<dialog>` or `popover` would + have used the top layer. + - Information that only exists in a tooltip. - Test files under `pages/` reaching the client bundle via an unfiltered eager glob. - Each page re-solving the Rails-errors shape locally — five copies in three disagreeing shapes is the observed steady state, not the worst case. ## See also - [Rails Production Playbook](https://wiki.joexbayer.dev/wiki/rails-production-playbook) — the server half of every contract above. - [Sandi Metz' Rules](https://wiki.joexbayer.dev/wiki/sandi-metz-rules) — now enforceable end-to-end. - [React: Rules & Project Structure (2026)](https://wiki.joexbayer.dev/wiki/react-best-practices) — component guard-rails; Parts 2–4 apply only to tier 3. - [Security Baseline: NSM Grunnprinsipper & EU Regulation](https://wiki.joexbayer.dev/wiki/security-baseline-nsm-and-eu) · [Red Team](https://wiki.joexbayer.dev/wiki/red-team) - [Kamal Auto-Versioning](https://wiki.joexbayer.dev/wiki/kamal-auto-version) — release stamping. + - [Inertia Rails](https://inertia-rails.dev/) — the adapter's own docs, and the + [cookbook](https://inertia-rails.dev/cookbook/integrating-shadcn-ui) this playbook defers to for + shadcn/ui setup, modals, slideovers and wizards.