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 overridesApplicationController 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
endTwo things to notice:
default_render: true— controllers can omit arender inertia:call; Rails infers the page name from the controller + action (DashboardController#index→pages/dashboard/index.tsx).inertia_shareis called on every Inertia request, so every page receivesauth.user(orauth.user: nullfor public routes). TheusePage().props.authtyping comes fromapp/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 typesA 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 inPersistentLayout, which mounts the Sonner<Toaster>and theuseFlashhook so Rails-sideflash[: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 (theuseFlashhook uses asetTimeout+ 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 scaffoldingThe 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
endconfig.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/ ]
endRoutes 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_checkinertia :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_ssrThis 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:
react()—@vitejs/plugin-reactwith the standard JSX + Fast Refresh.babel({ presets: [reactCompilerPreset()] })— the React Compiler (in stable as of React 19) auto-memoises components. NouseMemo/useCallbackboilerplate.tailwindcss()—@tailwindcss/vite(Tailwind v4) processesentrypoints/application.cssand emits a single stylesheet. There is notailwind.config.js— Tailwind v4 reads its@source,@theme,@layerdirectives from the CSS file itself.rails()—rails-vite-pluginproxies dev requests through Rails on port 3000 and writespublic/vite/manifest.jsonfor production.inertia({ ssr: "app/javascript/entrypoints/inertia.tsx" })— sets up the dual client + SSR build.npx vite build --ssrproducespublic/vite-ssr/ssr.jsfor 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 modelbin/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
| Directory | What it is | Touch it when |
|---|---|---|
bin/ | Rails 8 bin stubs + bin/ci, bin/dev, bin/setup, bin/kamal, bin/rubocop, bin/brakeman, bin/bundler-audit, bin/rspec | Running quality gates, the dev server, or Kamal commands |
lib/ | Empty lib/tasks/ — for your rake tasks | You 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 files | Never directly |
node_modules/ | npm install target | Never directly |
script/ | Project scripts | You add a one-off script |
vendor/ | Vendored dependencies (rare) | You vendor a gem |
tmp/, log/ | Rails runtime data | Never directly |
Getting started
Clone the Inertia + React starter, install Ruby + Node dependencies, point it at a Supabase project, and sign in for the first time.
Architecture
The Inertia request lifecycle, the Vite + React frontend layer, shadcn/ui wiring, and the Supabase integration points in the Inertia + React starter.