React Production Playbook (Inertia + Rails)
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
— 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_rails3.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 |
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:
// 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-headeris addressable as a
page — a partial rendered as a full response with none of its props — and a
colocatedSearch.test.tsxis 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 underpages/already has it. - Without the explicit
throw, a missing or renamed page resolves toundefined
and dies several frames deep inside React — a blank screen, not an error. The
controller'srender inertia:string and the file path are exactly the two
things worth printing when they disagree. Keep theknownlist: it turns a
five-minute bisect into a glance. (If you support both.jsxand.tsxduring
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
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
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
// 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
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
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.
// 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());
}
// 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:
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:
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() |
# 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.
// 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:
// 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:
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.
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.processingstops double-clicks, not network
retries. For money-moving actions, mintcrypto.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. - 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/healthcheck. - 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.
- Rails mints or accepts it at the edge (
X-Correlation-ID). - Shared prop carries it to the client.
- Tier-3 requests echo it back in the header (
apiFetchabove). problem+jsonand the Inertia error page both surface it to the user.- OTel browser SDK propagates
traceparentso 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. - 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 |
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.
detailsays 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 thereloadingslot
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
fetchoutsidelib/http.ts? - Any
useQueryoutsidepages/*/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/importingapi/, or a cross-slice deep import? - New top-level
.tsxin 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.errorsinline instead ofrailsErrors<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 topages/— 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
deferwould have handled. - The same datum available as both a prop and a Query resource.
problem+jsonforced through Inertia page visits.- React Hook Form fighting
useFormover 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 — the server half of every contract above.
- Sandi Metz' Rules — now enforceable end-to-end.
- React: Rules & Project Structure (2026) — component guard-rails; Parts 2–4 apply only to tier 3.
- Security Baseline: NSM Grunnprinsipper & EU Regulation · Red Team
- Kamal Auto-Versioning — release stamping.