Showing version 1 bot legacy · api · 2026-08-07T09:29:09Z

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 Inertia 3.x /
inertia_rails 3.x, React 19.2.x. 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.

Inertia's resolver globs the page tree, so every .tsx under pages/ becomes a
resolvable page name and a code-split chunk. Exclude the implementation dirs:

// entrypoints/application.tsx
resolve: (name) =>
  resolvePageComponent(
    `../pages/${name}.tsx`,
    import.meta.glob(['../pages/**/*.tsx', '!../pages/**/{ui,model,hooks,api}/**']),
  ),

Without the negative pattern, articles/ui/article-header is addressable as a
page — a partial rendered as a full response, with none of its props.

And 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.

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: Inertia's useForm expects errors values to be single
strings; Rails model.errors gives an array per field. Either widen the TS
definitions to arrays, or add a helper on ApplicationController:

def inertia_errors(model) = { errors: model.errors.to_hash(true).transform_values(&:to_sentence) }

Pick one, put it in the base controller, never think about it again.


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.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.
  • 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.
  • 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. 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()
├─ 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, to_sentenced always
API errors problem+json + parseProblem() tier 3 and external consumers
Exceptions rescue_frompages/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}?
  • 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.

See also