supabase-rb-rb
Inertia + React starter

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

The kit is a stock Rails 8.1 app with Inertia + Vite on top. Most of the day-to-day surface lives in two trees: app/javascript/ (the React side) and app/controllers/ (the Rails side that hands props over). This page walks both.

app/

app/controllers/

app/controllers/
├── application_controller.rb              # ActionController::Base + Authentication concern
├── inertia_controller.rb                  # parent for app-shell pages; shares auth.user
├── home_controller.rb                     # public "/"
├── dashboard_controller.rb                # authenticated "/dashboard"
├── users_controller.rb                    # account deletion via gem's admin API
├── concerns/
│   └── authentication.rb                  # wraps Supabase::Rails::Authentication
├── identity/
│   └── email_confirmations_controller.rb  # link target for the confirmation email
├── settings/
│   ├── emails_controller.rb               # show + update email via supabase_update_user
│   └── passwords_controller.rb            # show + update password via supabase_update_user
├── sessions_controller.rb                 # subclass — sign-in (email + password)
├── registrations_controller.rb            # subclass — sign-up + confirmation gating
├── passwords_controller.rb                # subclass — reset request + reset form
├── otp_controller.rb                      # subclass — magic-link request + verify
└── oauth_controller.rb                    # subclass — no overrides

ApplicationController inherits from ActionController::Base and includes the Authentication concern. That concern is a one-liner — it just includes Supabase::Rails::Authentication, which installs before_action :require_authentication on every action and exposes Current.user, Current.session, and allow_unauthenticated_access.

InertiaController is the kit's signature pattern. App-shell pages (dashboard, settings) inherit from it rather than ApplicationController directly:

class InertiaController < ApplicationController
  inertia_config default_render: true
  inertia_share auth: -> { { user: current_user_props } }

  private

  def current_user_props
    # shapes Current.user (Supabase::Rails::User value object) into
    # the { id, email, name, avatar, verified } shape the React tree expects
  end
end

Two things to notice:

  1. default_render: true — controllers can omit a render inertia: call; Rails infers the page name from the controller + action (DashboardController#indexpages/dashboard/index.tsx).
  2. inertia_share is called on every Inertia request, so every page receives auth.user (or auth.user: null for public routes). The usePage().props.auth typing comes from app/javascript/types/index.ts.

The auth subclass controllers (SessionsController, RegistrationsController, PasswordsController, OtpController, OauthController) subclass Supabase::Rails::*Controller and add inertia_config default_render: true. They render React pages under pages/sessions/, pages/registrations/, etc., instead of ERB.

app/javascript/ — the React tree

app/javascript/
├── entrypoints/
│   ├── application.css                     # Tailwind v4 entrypoint (@import "tailwindcss"; plus @source rules)
│   └── inertia.tsx                         # createInertiaApp call — Inertia bootstrap
├── pages/                                  # Inertia page components (auto-resolved by createInertiaApp)
│   ├── home/index.tsx
│   ├── dashboard/index.tsx
│   ├── sessions/new.tsx
│   ├── registrations/new.tsx
│   ├── passwords/{new,edit}.tsx
│   ├── otp/{new,verify}.tsx
│   └── settings/
│       ├── appearance.tsx
│       ├── emails/show.tsx
│       └── passwords/show.tsx
├── components/                             # reusable presentational components
│   ├── app-shell.tsx, app-sidebar.tsx, app-header.tsx, app-sidebar-header.tsx
│   ├── nav-main.tsx, nav-footer.tsx, nav-user.tsx, user-menu-content.tsx
│   ├── breadcrumbs.tsx, heading.tsx, heading-small.tsx
│   ├── delete-user.tsx, appearance-dropdown.tsx, appearance-tabs.tsx
│   ├── alert-error.tsx, input-error.tsx, text-link.tsx
│   ├── icon.tsx, placeholder-pattern.tsx
│   └── ui/                                 # shadcn/ui primitives — alert, button, card, dialog, field, input, sidebar, sonner, …
├── layouts/                                # Inertia per-page layout wrappers
│   ├── persistent-layout.tsx               # mounted via createInertiaApp's `layout:` — wraps every page
│   ├── app-layout.tsx                      # signed-in chrome (sidebar + header)
│   ├── app/                                # variants (sidebar layout vs header layout)
│   ├── auth-layout.tsx                     # auth chrome
│   ├── auth/                               # card / simple / split variants
│   └── settings/layout.tsx                 # settings sub-nav (Email / Password / Appearance)
├── hooks/
│   ├── use-appearance.tsx                  # localStorage-backed theme switcher
│   ├── use-flash.tsx                       # converts Rails flash → Sonner toast on Inertia nav
│   ├── use-clipboard.ts, use-initials.tsx, use-mobile.ts, use-mobile-navigation.ts
├── lib/
│   ├── utils.ts                            # `cn(...)` — clsx + tailwind-merge (shadcn standard)
│   ├── browser.ts, storage.ts              # SSR-safe `isBrowser`, localStorage wrappers
├── routes/                                 # GENERATED by Typelizer — DO NOT EDIT
│   ├── DashboardController.ts, SessionsController.ts, …
│   ├── Identity/, Settings/                # nested namespaces
│   └── index.ts                            # re-exports { dashboard, sessions, …, newSession, … }
└── types/
    ├── index.ts                            # SharedProps, Auth, User, NavItem, BreadcrumbItem, FlashData
    ├── globals.d.ts                        # ambient module declarations
    └── vite-env.d.ts                       # Vite's env types

A few directories deserve their own paragraph.

entrypoints/inertia.tsx

The Vite entrypoint. It calls createInertiaApp({ pages: "../pages", layout: () => [PersistentLayout], … }). Three patterns to know:

  • pages: "../pages" — Inertia resolves page names ("dashboard/index") against this directory. Page imports are code-split automatically.
  • layout: () => [PersistentLayout] — every page is wrapped in PersistentLayout, which mounts the Sonner <Toaster> and the useFlash hook so Rails-side flash[:notice]/flash[:alert] show up as toasts on the next Inertia navigation.
  • strictMode: true — React 19 Strict Mode is on. Side effects in your components have to be idempotent (the useFlash hook uses a setTimeout + cleanup pattern to avoid double-firing).

pages/

Each *.tsx is a default-exported React component. The file path under pages/ is the page name the controller passes:

# dashboard_controller.rb
def index
  # default_render → renders pages/dashboard/index.tsx
end
// pages/dashboard/index.tsx
export default function Dashboard() {
  return (
    <AppLayout breadcrumbs={[{ title: "Dashboard", href: dashboard.index().url }]}>
      <Head title="Dashboard" />

    </AppLayout>
  )
}

The dashboard ships empty — three <PlaceholderPattern /> cards. That's deliberate: you're meant to replace them with your own resource on day one.

components/ui/

shadcn/ui primitives generated through npx shadcn@latest add <component>. The components.json at the repo root configures the generator — style new-york, base color neutral, CSS variables on, Lucide as the icon library. Adding a new primitive (npx shadcn@latest add tabs) writes a tabs.tsx here and updates any cross-references.

routes/

Generated by Typelizer (gem "typelizer") on every server reload. The first line of each file is a comment:

// Typelizer digest ce290b83826f6daeebe25395c38e91c8
//
// DO NOT MODIFY: This file was automatically generated by Typelizer.

If you change config/routes.rb, regenerate by restarting bin/dev or by running the Typelizer rake task (bin/rails typelizer:generate).

Usage from a page or component:

import { dashboard, sessions, settingsPasswords } from "@/routes"

dashboard.index().url           // "/dashboard"
sessions.create()               // { url: "/session", method: "POST" }
settingsPasswords.show().url    // "/settings/password"

Path alias @

tsconfig.json maps @/* to app/javascript/*. import { Button } from "@/components/ui/button" works because of that mapping (and a matching resolve.alias in Vite's plugin chain). components.json's aliases (@/components, @/lib, @/hooks) all flow from the same root.

app/views/

app/views/
├── layouts/
│   ├── application.html.erb                # the only layout — boots Inertia + vite_tags
│   └── mailer.{html,text}.erb              # outbound email chrome (unused — Supabase sends mail)
├── pwa/                                    # default Rails PWA scaffolding
└── user_mailer/                            # default Rails mailer scaffolding

The application.html.erb layout is the only ERB you'll touch — it ships <%= vite_tags "application.css", "inertia.tsx" %> and <%= inertia_ssr_head %>. The inline <script> reads localStorage.appearance before paint to avoid FOUC; the <body> just yields the Inertia root.

There are no app/views/supabase/rails/ overrides — the auth pages render through Inertia (pages/sessions/new.tsx, etc.), not through the gem's bundled ERB. The gem's controllers still own the request lifecycle; only the templates are swapped.

app/models/, app/helpers/, app/jobs/, app/mailers/

Defaults from the Rails generator. The mailer layouts ship for future use; the kit itself sends no email — Supabase Auth handles confirmation, password reset, and OTP delivery.

app/models/current.rb declares :user and :session. The Supabase::Rails::Authentication concern populates both on every authenticated request. There is no User ActiveRecord model — see Architecture → Why no User model for the trade-off.

config/

config/initializers/inertia_rails.rb

InertiaRails.configure do |config|
  config.version                          = RailsVite.digest
  config.encrypt_history                  = Rails.env.production?
  config.always_include_errors_hash       = true
  config.use_script_element_for_initial_page = true
  config.use_data_inertia_head_attribute  = true
  config.parent_controller                = "::InertiaController"

  config.ssr_enabled = false
end

config.version = RailsVite.digest triggers a full reload (instead of an Inertia swap) when the asset bundle changes — so a deploy invalidates in-flight cached HTML. config.parent_controller = "::InertiaController" is how the inertia_share auth: … block gets re-applied across every Inertia controller without having to repeat it.

Flip ssr_enabled = true and rebuild the image with SSR_ENABLED=true to ship SSR. See Architecture → SSR for the runtime story.

config/initializers/supabase.rb

Rails.application.config.supabase.mode                     = :web
Rails.application.config.supabase.oauth_providers          = []
Rails.application.config.supabase.allowed_redirect_origins = []

Same :web-mode pattern as the Hotwire kit — the gem installs the cookie-session middleware and the Web::CookieCredentialStrategy. Commented-out blocks show the session-cookie tunables (cookie_name, same_site, secure, domain, path).

oauth_providers = [] is the OAuth-disabled default — the kit doesn't ship a "Continue with GitHub" surface (the API + Hotwire kits do). Add your providers here when you wire OAuth on.

config/initializers/typelizer.rb

Typelizer.configure do |config|
  config.routes.enabled    = true
  config.routes.output_dir = Rails.root.join("app/javascript/routes")
  config.routes.exclude    = [ /^\/rails/, /^\/up/ ]
end

Routes inside /rails (engine internals) and /up (health check) are excluded — everything else gets a typed controller helper.

config/routes.rb

supabase_authentication_routes

resource :users, only: [ :destroy ]

namespace :identity do
  resource :email_confirmation, only: [ :show ]
end

get :dashboard, to: "dashboard#index"

namespace :settings do
  resource :password, only: [ :show, :update ]
  resource :email,    only: [ :show, :update ]
  inertia :appearance              # Inertia helper: GET /settings/appearance → renders pages/settings/appearance.tsx
end

root "home#index"

get "up" => "rails/health#show", as: :rails_health_check

inertia :appearance is a one-liner Inertia helper — it declares a GET route that renders a page directly, with no controller action needed. Use it for pages that are pure UI (the appearance switcher writes to localStorage, so no server state is touched).

supabase_authentication_routes expands to the gem's sign-in / sign-up / password / OTP / OAuth route table. The kit's subclass controllers take those routes over and render React pages.

config/puma.rb

The only non-default line:

plugin :inertia_ssr

This plugin (from inertia_rails) boots a Node.js subprocess that loads the prebuilt SSR bundle at public/vite-ssr/ssr.js and serves render requests over a Unix socket — but only when InertiaRails.configuration.ssr_enabled is true. When SSR is off (the default), the plugin is a no-op and Puma boots without it.

vite.config.ts

import inertia from "@inertiajs/vite"
import babel from "@rolldown/plugin-babel"
import tailwindcss from "@tailwindcss/vite"
import react, { reactCompilerPreset } from "@vitejs/plugin-react"
import rails from "rails-vite-plugin"

export default defineConfig(({ command }) => ({
  ssr: { … },
  plugins: [
    react(),
    babel({ presets: [reactCompilerPreset()] }),
    tailwindcss(),
    rails(),
    inertia({ ssr: "app/javascript/entrypoints/inertia.tsx" }),
  ],
}))

Five plugins in order:

  1. react()@vitejs/plugin-react with the standard JSX + Fast Refresh.
  2. babel({ presets: [reactCompilerPreset()] }) — the React Compiler (in stable as of React 19) auto-memoises components. No useMemo/useCallback boilerplate.
  3. tailwindcss()@tailwindcss/vite (Tailwind v4) processes entrypoints/application.css and emits a single stylesheet. There is no tailwind.config.js — Tailwind v4 reads its @source, @theme, @layer directives from the CSS file itself.
  4. rails()rails-vite-plugin proxies dev requests through Rails on port 3000 and writes public/vite/manifest.json for production.
  5. inertia({ ssr: "app/javascript/entrypoints/inertia.tsx" }) — sets up the dual client + SSR build. npx vite build --ssr produces public/vite-ssr/ssr.js for the Node renderer.

The ssr object handles a React-19-specific quirk: in dev (command === "serve"), React 19 is externalised so Node resolves the CJS package natively; in build (command === "build"), everything is bundled (noExternal: true) so the SSR JS runs without node_modules.

config/database.yml

The kit runs on SQLite for all four roles — primary (app data), cache (Solid Cache), queue (Solid Queue), cable (Solid Cable). Production declares the four files under storage/. There is an e2e Rails environment that mirrors test, but no bin/e2e ships in this kit — the suite under spec/requests/ is the only test runner.

config/deploy.yml

Kamal config — service: react_starter_kit, container image, registry, secret list, persistent volume for storage/, and a commented-out proxy block for SSL termination. Edit before deploying. See Deployment for the production checklist.

supabase/

supabase/
├── config.toml                            # `supabase start` configuration
├── snippets/                               # optional local SQL snippets (empty by default)
└── templates/                              # optional email/SQL templates (empty by default)

The checked-in supabase/config.toml lets supabase start boot a full local stack without supabase init. The API runs on :54321, Postgres on :54322, Studio on :54323, Mailpit on :54324.

Unlike the Hotwire kit, this starter does not ship a supabase/migrations/ directory — there's no domain table out of the box. When you add your first table, run supabase migration new <name> to create one; supabase start applies anything under supabase/migrations/ on boot.

db/

db/
├── seeds.rb                                # empty
├── schema.rb                               # generated; tracks the primary SQLite DB
├── cache_schema.rb                         # Solid Cache
├── queue_schema.rb                         # Solid Queue
├── cable_schema.rb                         # Solid Cable
└── migrate/                                # empty until you add your first AR model

bin/rails db:prepare runs migrate/ against primary and applies the three *_schema.rb files to the matching Solid SQLite databases. When you add your first domain model, Rails populates migrate/ as usual.

spec/

spec/
├── spec_helper.rb                          # RSpec config — disable_monkey_patching, random order, profile flag
├── rails_helper.rb                         # Rails integration + system test driver (Selenium headless Chrome)
├── support/
│   └── supabase_auth_helpers.rb            # sign_in_as / sign_out helpers + Supabase::Rails::User stub
└── requests/                               # request specs — one file per controller
    ├── dashboard_spec.rb
    ├── sessions_spec.rb
    ├── registrations_spec.rb
    ├── passwords_spec.rb
    ├── otp_spec.rb
    ├── identity/
    └── settings/

SupabaseAuthHelpers#sign_in_as(email:, name:) stubs resume_session on ApplicationController so any request spec can short-circuit the cookie-session round-trip. The default user UUID is 00000000-0000-0000-0000-000000000001 — override id: to test multi-user behaviour.

The system-test infrastructure (Capybara + Selenium headless Chrome at 1400×1400) is configured in rails_helper.rb, but the kit ships no system specs out of the box. Add spec/system/ when you want browser-driven assertions.

Other directories

DirectoryWhat it isTouch it when
bin/Rails 8 bin stubs + bin/ci, bin/dev, bin/setup, bin/kamal, bin/rubocop, bin/brakeman, bin/bundler-audit, bin/rspecRunning quality gates, the dev server, or Kamal commands
lib/Empty lib/tasks/ — for your rake tasksYou add a rake task
public/Default Rails 8 error pages + favicon; Vite writes public/vite/ (client bundle) and optionally public/vite-ssr/ (SSR bundle)You change error pages or the favicon
storage/SQLite database filesNever directly
node_modules/npm install targetNever directly
script/Project scriptsYou add a one-off script
vendor/Vendored dependencies (rare)You vendor a gem
tmp/, log/Rails runtime dataNever directly

On this page