supabase-rb-rb
Inertia + React starter

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 cookie

The 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)

  1. The browser requests /dashboard. No X-Inertia header.
  2. The Supabase::Rails::Middleware resolves the cookie, sets Current.user, and continues.
  3. DashboardController < InertiaController runs #index. default_render: true infers pages/dashboard/index.tsx.
  4. Rails renders application.html.erb, which inlines a <script id="app" data-page="…"> element containing the initial Inertia payload (the page name, props, the shared auth block, the asset version). <%= vite_tags … %> injects the client bundle.
  5. The bundle boots, createInertiaApp reads #app, mounts React, and renders Dashboard against the props.

Inertia navigation (JSON)

  1. The user clicks <Link href={settingsPasswords.show()}>. Inertia sets the X-Inertia header and fetches /settings/password.
  2. Same middleware → controller → render path on the server.
  3. Inertia detects the X-Inertia header on the response side (Inertia Rails sets it automatically) and serialises the page as JSON instead of HTML.
  4. 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 via history.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, so auth.user stays in sync (e.g. after a profile update).
  • flash is forwarded automatically. Rails's flash ends up under props.flash, and PersistentLayout's useFlash hook converts it to a Sonner toast on the next render. No redirect_to … notice: "…" plumbing is needed in React.
  • Errors are a first-class prop. config.always_include_errors_hash = true means every Inertia response carries an errors: { … } object (empty when there are none). React's <Form> from @inertiajs/react reads 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
end

Two things to notice:

  1. current_user_props shapes Supabase::Rails::User for React. The gem's User value 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 — change current_user_props (e.g. pull tier from user_metadata) and every page picks up the new field.
  2. auth.user = nil on public routes. Current.user is nil for unauthenticated requests, and the shared block returns { user: nil }. React's usePage().props.auth.user is typed as User in types/index.ts, but pages that render publicly (pages/home/index.tsx) check for null before 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/indexpages/dashboard/index.tsx
sessions/newpages/sessions/new.tsx
settings/passwords/showpages/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

NeedReach for
Accessible dropdown / dialog / popover / select / tooltipshadcn (Radix-backed)
Marketing surface / one-off cardYour own component
Form field with label + error renderingshadcn's Field primitive (kit uses it on every auth page)
Toast notificationshadcn'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 encrypted sb-session cookie and manages refresh.
  • Supabase::Rails::SessionStore — the encrypted-cookie session store backing the strategy.
  • The full supabase_authentication_routes table — sessions, registrations, passwords, OTP, OAuth.

Same shape as the Hotwire kit:

  1. On request in. The middleware reads the encrypted sb-session cookie. If present and decryptable, it loads the stored Supabase access + refresh tokens, checks expiry, and refreshes the access token if needed.
  2. Populates Current. Verified claims become a Supabase::Rails::User value object on Current.user. The session itself goes on Current.session.
  3. On the way out. If the access token was refreshed mid-request, the middleware rewrites the sb-session cookie. The cookie is HttpOnly, SameSite=Lax, and Secure when 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
end

Two 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> reads errors.email automatically because always_include_errors_hash is on.
  • redirect_to … notice: "…" on success becomes a Sonner toast in the next page via useFlash. 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:

  1. Flip config.ssr_enabled = true in config/initializers/inertia_rails.rb.
  2. Build the production image with --build-arg SSR_ENABLED=true so the SSR bundle ships in public/vite-ssr/ssr.js.

What the SSR build does:

  • npx vite build --ssr produces a single ssr.js that exports the same createInertiaApp graph as the client entrypoint, but renders to a string on the server.
  • The inertia_ssr Puma plugin spawns a long-running node ssr.js process, 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_error falls 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.user is the verified session, exposed to React as auth.user. Zero local user state, zero sync drift between Supabase and Rails. Domain models foreign-key directly to Current.user.id (a UUID).
  • AR User — what bin/rails generate supabase:user_model gives you. A users table keyed by id = Current.user.id, populated lazily or by webhook. You get belongs_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.

On this page