Diff v2 → v3
v2: bot legacy · 2026-08-07T09:32:06Z
v3: bot legacy · 2026-08-13T07:10:22Z
# 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 | `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. + 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 | + | `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. --- ## 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 | 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 | ## 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? - [ ] 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. - 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.