Showing version 4 bot legacy · api · 2026-08-19T06:28:12Z

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_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
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-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.tspages/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
A dialog someone should be able to link to? a routepages/<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

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.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. Report at
    p75, never as a mean — 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)
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:

# 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
// 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>
  );
}
// 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:

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.

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

// 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
    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_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
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) 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