Architecture
The Inertia request lifecycle, the Vite + React frontend layer, shadcn/ui wiring, and the Supabase integration points in the Inertia + React starter.
The starter is a Rails 8.1 monolith — Inertia owns the seam between Rails controllers and React pages, Vite drives the build, and supabase-rails in :web mode handles the auth surface. This page covers each layer so you can reason about (and safely modify) the request lifecycle.
High-level shape
Browser ─► Rails (Inertia JSON or full-page HTML)
│ │
│ ├─ Supabase::Rails::Middleware
│ │ ↳ reads encrypted sb-session cookie
│ │ ↳ refreshes Supabase access token if needed
│ │ ↳ populates Current.user + Current.session
│ │
│ ├─ InertiaController (parent)
│ │ ↳ shares { auth: { user: current_user_props } } on every page
│ │
│ └─ Action ─► render inertia: "<page>", props: { … }
│ │
│ ▼
│ React (Vite-served or SSR-rendered)
│
└─► Supabase Auth (sign-up, password reset, magic-link, OAuth)
↳ redirects back through Rails so the gem can mint the cookieThe browser holds an encrypted sb-session cookie, not a token — the cookie carries the Supabase access + refresh tokens, the gem unwraps them server-side, and React only ever sees the user object through the shared auth.user prop.
Inertia request lifecycle
Inertia is best understood as a "controllers return props, the page is React" abstraction. There are two request shapes and four steps that vary slightly between them.
First visit (full HTML)
- The browser requests
/dashboard. NoX-Inertiaheader. - The
Supabase::Rails::Middlewareresolves the cookie, setsCurrent.user, and continues. DashboardController < InertiaControllerruns#index.default_render: trueinferspages/dashboard/index.tsx.- Rails renders
application.html.erb, which inlines a<script id="app" data-page="…">element containing the initial Inertia payload (the page name, props, the sharedauthblock, the asset version).<%= vite_tags … %>injects the client bundle. - The bundle boots,
createInertiaAppreads#app, mounts React, and rendersDashboardagainst the props.
Inertia navigation (JSON)
- The user clicks
<Link href={settingsPasswords.show()}>. Inertia sets theX-Inertiaheader and fetches/settings/password. - Same middleware → controller → render path on the server.
- Inertia detects the
X-Inertiaheader on the response side (Inertia Rails sets it automatically) and serialises the page as JSON instead of HTML. - The client receives
{ component: "settings/passwords/show", props: { … }, version: "…" }, dynamically imports the page module via Vite's code-splitting, swaps the React tree, and updates the URL viahistory.pushState.
A few invariants the kit relies on:
- Shared props re-evaluate per request.
inertia_share auth: -> { … }runs on every Inertia request, including JSON-only ones, soauth.userstays in sync (e.g. after a profile update). flashis forwarded automatically. Rails'sflashends up underprops.flash, andPersistentLayout'suseFlashhook converts it to a Sonner toast on the next render. Noredirect_to … notice: "…"plumbing is needed in React.- Errors are a first-class prop.
config.always_include_errors_hash = truemeans every Inertia response carries anerrors: { … }object (empty when there are none). React's<Form>from@inertiajs/reactreads it directly.
InertiaController + shared auth
class InertiaController < ApplicationController
inertia_config default_render: true
inertia_share auth: -> { { user: current_user_props } }
private
def current_user_props
user = Current.user
return nil if user.nil?
metadata = user.user_metadata.is_a?(Hash) ? user.user_metadata : {}
raw = user.raw.is_a?(Hash) ? user.raw : {}
{
id: user.id,
email: user.email,
name: metadata["name"] || metadata["full_name"] || user.email,
avatar: metadata["avatar_url"] || metadata["avatar"],
verified: raw["email_verified"] == true || metadata["email_verified"] == true
}
end
endTwo things to notice:
current_user_propsshapesSupabase::Rails::Userfor React. The gem'sUservalue object has the verified JWT claims; the React tree wants a flat{ id, email, name, avatar, verified }shape. The mapping lives here, in one place — changecurrent_user_props(e.g. pulltierfromuser_metadata) and every page picks up the new field.auth.user = nilon public routes.Current.userisnilfor unauthenticated requests, and the shared block returns{ user: nil }. React'susePage().props.auth.useris typed asUserintypes/index.ts, but pages that render publicly (pages/home/index.tsx) check fornullbefore deref'ing.
If you need a second shared prop (say, flags: -> { feature_flags_for(Current.user) }), call inertia_share again — Inertia merges. Don't fan it out across individual controllers.
Vite + React frontend layer
Vite in dev vs prod
In development, bin/dev starts Vite on port 5173 alongside bin/rails s on port 3000. The rails-vite-plugin middleware mounts inside the Rails request pipeline and proxies /vite/* and @vite/* requests through to the Vite dev server — so HMR, fast refresh, and module graph all work as if Vite was serving directly, but the only URL the browser ever sees is http://localhost:3000.
In production, bin/rails assets:precompile runs npx vite build (and npx vite build --ssr when SSR_ENABLED=true is set). That writes public/vite/ (client bundle + manifest) and public/vite-ssr/ssr.js. The <%= vite_tags … %> helper reads public/vite/manifest.json and emits the right <script> / <link> tags for the digested filenames. Thruster (the included static-asset accelerator) serves them.
React Compiler
The Babel preset wired into Vite (@rolldown/plugin-babel with reactCompilerPreset()) auto-memoises components — you don't need useMemo or useCallback for cache invalidation. Keep your components pure; if you do reach for useMemo later, that's a hint the compiler bailed out (usually on a non-statically-analysable closure).
Page resolution
createInertiaApp({ pages: "../pages", … }) resolves page names against app/javascript/pages/:
| Page name (from Rails) | File |
|---|---|
dashboard/index | pages/dashboard/index.tsx |
sessions/new | pages/sessions/new.tsx |
settings/passwords/show | pages/settings/passwords/show.tsx |
The default_render: true config means DashboardController#index resolves to pages/dashboard/index.tsx without an explicit render inertia:. You can still call render inertia: "some/other/page", props: { … } to override.
Tailwind v4 + application.css
Tailwind v4 has no tailwind.config.js — the entrypoint entrypoints/application.css carries everything:
@import "tailwindcss";
@import "tw-animate-css";
@source "../**/*.tsx";
@source "../../views/**/*.erb";
@theme { … }@source directives tell Tailwind where to scan for class usage. The kit defaults cover the React tree and the (almost empty) ERB tree. When you add a new directory of components in a non-default location (app/javascript/widgets/), add a matching @source line.
Theme tokens (@theme { … }) drive shadcn's CSS variables (--background, --foreground, --primary, etc.) for both light and dark modes. The shadcn primitives reference those variables — change a token and every primitive picks it up.
shadcn/ui
components.json at the repo root is the shadcn CLI's config:
{
"style": "new-york",
"tsx": true,
"tailwind": {
"config": "",
"css": "app/javascript/entrypoints/application.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}Running npx shadcn@latest add tabs reads this file, fetches the tabs source from the shadcn registry, transforms imports against the alias map, and writes app/javascript/components/ui/tabs.tsx. Because the kit owns the source (it's not a dependency), you can edit any primitive directly — components/ui/button.tsx is yours to fork.
The shipped primitives (~25 of them) cover the surface most apps need: alert, avatar, badge, breadcrumb, button, card, checkbox, collapsible, dialog, dropdown-menu, field, input, label, navigation-menu, select, separator, sheet, sidebar, skeleton, sonner, spinner, toggle, toggle-group, tooltip. All are built on Radix UI (accessibility primitives), styled with Tailwind v4 + class-variance-authority (variant-aware className builders), and merged with tailwind-merge via the cn(...) helper in lib/utils.ts.
When to reach for shadcn vs your own primitive
| Need | Reach for |
|---|---|
| Accessible dropdown / dialog / popover / select / tooltip | shadcn (Radix-backed) |
| Marketing surface / one-off card | Your own component |
| Form field with label + error rendering | shadcn's Field primitive (kit uses it on every auth page) |
| Toast notification | shadcn's sonner (already mounted in PersistentLayout) |
The kit's auth pages (sessions/new.tsx, registrations/new.tsx, etc.) are good reference uses of Field, Input, Button, Spinner, Form working together.
Supabase integration points
The kit wires four distinct things from supabase-rails.
1. Mode
config.supabase.mode = :web (in config/initializers/supabase.rb) installs the cookie-session machinery. The gem mounts:
Web::CookieCredentialStrategy— reads/writes the encryptedsb-sessioncookie and manages refresh.Supabase::Rails::SessionStore— the encrypted-cookie session store backing the strategy.- The full
supabase_authentication_routestable — sessions, registrations, passwords, OTP, OAuth.
2. Cookie session lifecycle
Same shape as the Hotwire kit:
- On request in. The middleware reads the encrypted
sb-sessioncookie. If present and decryptable, it loads the stored Supabase access + refresh tokens, checks expiry, and refreshes the access token if needed. - Populates
Current. Verified claims become aSupabase::Rails::Uservalue object onCurrent.user. The session itself goes onCurrent.session. - On the way out. If the access token was refreshed mid-request, the middleware rewrites the
sb-sessioncookie. The cookie isHttpOnly,SameSite=Lax, andSecurewhen behind TLS.
If decryption fails (key rotation, tampered cookie) or refresh fails (revoked session, expired refresh token), the middleware drops Current.user to nil. Supabase::Rails::Authentication's require_authentication then redirects to /session/new.
3. Auth controllers + Inertia rendering
Each auth controller subclasses Supabase::Rails::*Controller and adds inertia_config default_render: true so the gem's actions render React pages instead of the bundled ERB:
class SessionsController < Supabase::Rails::SessionsController
inertia_config default_render: true
allow_unauthenticated_access only: %i[destroy]
before_action :redirect_if_authenticated, only: %i[new create]
def create
if (supabase_session = authenticate_with_supabase(email: params[:email], password: params[:password]))
start_new_session_for(supabase_session)
redirect_to after_authentication_url, notice: I18n.t("supabase.rails.sessions.created")
else
flash.now[:alert] = I18n.t("supabase.rails.sessions.invalid")
render inertia: "sessions/new",
props: { errors: { email: [ I18n.t("supabase.rails.sessions.invalid") ] } },
status: :unprocessable_content
end
end
endTwo patterns to copy when you add your own form-driven flow:
flash.now[:alert]+render inertia: …is how you return a 422 with field errors. The React tree's<Form>readserrors.emailautomatically becausealways_include_errors_hashis on.redirect_to … notice: "…"on success becomes a Sonner toast in the next page viauseFlash. No client-side success handling needed.
4. Account updates
Settings::EmailsController#update and Settings::PasswordsController#update call supabase_update_user(email: …) / supabase_update_user(password: …) — gem helpers that wrap Supabase's auth.updateUser admin endpoint. They return a Result you pattern-match against to render the flash + re-render the same page on error.
There's no AR model to permit params against; the form posts straight to Supabase Auth via the gem.
Optional SSR
Inertia SSR is wired but disabled. With SSR on, the first page render happens in a Node subprocess (booted by Puma's inertia_ssr plugin) and ships pre-rendered HTML; subsequent Inertia navigations are still JSON-driven on the client.
To turn it on:
- Flip
config.ssr_enabled = trueinconfig/initializers/inertia_rails.rb. - Build the production image with
--build-arg SSR_ENABLED=trueso the SSR bundle ships inpublic/vite-ssr/ssr.js.
What the SSR build does:
npx vite build --ssrproduces a singlessr.jsthat exports the samecreateInertiaAppgraph as the client entrypoint, but renders to a string on the server.- The
inertia_ssrPuma plugin spawns a long-runningnode ssr.jsprocess, opens a Unix socket, and handles render requests from Rails by forwarding the page name + props. - Rails calls into the SSR socket on the first request; if it fails (timeout, crash, missing bundle),
config.on_ssr_errorfalls back to client-side rendering.
In dev, set ssr_enabled = true and Vite serves SSR through its own dev endpoint with HMR — no separate process needed. The Docker build arg only matters for production images.
When SSR is worth it
- Public surfaces. Marketing pages, blog posts, search-engine-visible content — SSR sends rendered HTML for SEO and faster first paint.
- Slow connections. The first-paint win is real on 3G / spotty wifi; subsequent navigations are still fast because Inertia takes over.
When to skip SSR
- All-authenticated apps. Dashboards behind sign-in don't need SEO; the React shell loads fast enough on broadband. The build complexity isn't worth it.
- Heavy client-only dependencies. If your bundle pulls in something that requires
window(older charting libs, etc.), SSR will throw and the fallback will kick in on every request — net negative.
Why no User model
Same trade-off as the API and Hotwire kits:
- No AR
User— what the kit does.Current.useris the verified session, exposed to React asauth.user. Zero local user state, zero sync drift between Supabase and Rails. Domain models foreign-key directly toCurrent.user.id(a UUID). - AR
User— whatbin/rails generate supabase:user_modelgives you. Auserstable keyed byid = Current.user.id, populated lazily or by webhook. You getbelongs_to :user, eager loading, reflections — at the cost of keeping the table in sync with Supabase Auth.
The kit ships without it because most Inertia + React apps don't need it on day one. Add it the day you have a model that needs belongs_to :user or a profile field you don't want to store as Supabase user metadata.
Project structure
A directory-by-directory walkthrough of the Inertia + React starter — app/, config/, supabase/, db/, and spec/ — with extra depth on the React tree under app/javascript/.
Customization
Inertia + React recipes for extending the starter — add a new Inertia page, add a shadcn component, and wire a Supabase Realtime channel into a React component.