Project structure
A directory-by-directory walkthrough of the Hotwire starter — app/, config/, supabase/, db/, and test/ — at the level of detail a new contributor needs.
The kit is a stock Rails 8.1 app with a small surface of starter-kit-specific files. This page walks each top-level directory in the order you'll touch them.
app/
Everything custom to the kit lives here. Standard Rails directories (jobs/, mailers/, helpers/) are mostly defaults; the action is in components/, controllers/, views/, and javascript/.
app/controllers/
app/controllers/
├── application_controller.rb # ActionController::Base + Authentication
├── home_controller.rb # `/` (public) + `/dashboard` (auth)
├── pages_controller.rb # public `/welcome`
├── notes_controller.rb # RLS-scoped CRUD through PostgREST
├── concerns/
│ └── authentication.rb # wraps Supabase::Rails::Authentication
├── settings/
│ ├── profiles_controller.rb # display name + email via gem's update_user
│ └── appearances_controller.rb # theme switcher (cookie-backed at JS level)
├── sessions_controller.rb # subclass — auth layout + welcome_path on destroy
├── registrations_controller.rb # subclass — auth layout
├── passwords_controller.rb # subclass — auth layout
├── otp_controller.rb # subclass — auth layout
└── oauth_controller.rb # subclass — no overridesApplicationController inherits from ActionController::Base and includes the Authentication concern from concerns/. The concern bundles two things:
include Supabase::Rails::Authentication— installsbefore_action :require_authenticationon every action, exposesCurrent.user, and wires up theallow_unauthenticated_accessmacro.prepend ExpiredSessionFlash— an override ofrequest_authenticationthat attaches a"Your session has expired"flash before redirecting to sign-in, but only when the request arrived with ansb-sessioncookie that the middleware just had to invalidate (expired refresh token, tampered ciphertext). Theprependis what makes it win method-resolution against the gem's ownrequest_authentication.
HomeController is the most interesting public route — it uses allow_unauthenticated_access only: :index, unless: -> { request.path == dashboard_path } so the same controller serves both the public landing variant (/) and the gated dashboard (/dashboard).
SessionsController, RegistrationsController, PasswordsController, OtpController, and OauthController all subclass Supabase::Rails::*Controller. The only overrides are layout "auth" (so they render on the dedicated auth chrome) and SessionsController#destroy, which lands on /welcome instead of / after sign-out.
NotesController is the kit's reference example for per-request RLS through the gem's Supabase client. See Architecture → Per-request Supabase client for what request.env[Supabase::Rails::CONTEXT_KEY].supabase resolves to.
app/components/
app/components/
├── application_component.rb # base class — exposes `icon` helper
├── app_logo_component.{rb,html.erb} # brand mark + name
├── app_logo_icon_component.{rb,html.erb} # mark-only variant for tight slots
├── auth_header_component.{rb,html.erb} # title + description for the auth chrome
├── auth_session_status_component.{rb,html.erb} # "Already signed in / Not signed in" callout
├── avatar_component.{rb,html.erb}
├── button_component.{rb,html.erb}
├── placeholder_pattern_component.{rb,html.erb}
├── separator_component.{rb,html.erb}
├── sidebar_component.{rb,html.erb} # navigation chrome — driven by Current.user + request.path
└── user_menu_component.{rb,html.erb} # avatar + dropdown in the top-rightViewComponent is in use across app/views/ — open any layout or view and you'll see render SidebarComponent.new(...) rather than partials. The base ApplicationComponent is where shared helpers (including lucide-rails' icon helper) get re-exposed inside components.
SidebarComponent is illustrative: its initialiser takes user: and current_path:, and its current?(href) method computes the active state for each nav item by prefix-matching request.path. Add a new sidebar item by extending its nav_items array — see Customization → Add a sidebar entry.
app/views/
app/views/
├── home/index.html.erb # dashboard shell (`/` and `/dashboard`)
├── pages/welcome.html.erb # public landing
├── notes/index.html.erb # signed-in user's notes list
├── settings/
│ ├── profiles/show.html.erb # display name + email form
│ └── appearances/show.html.erb # theme switcher
├── layouts/
│ ├── application.html.erb # authenticated chrome (sidebar + header)
│ ├── auth.html.erb # auth chrome — wraps Tailwind auth surfaces
│ ├── _card.html.erb _simple.html.erb _split.html.erb # auth chrome variants
│ ├── _head.html.erb # shared head — theme init, Importmap, Railsblocks CDN
│ ├── mailer.{html,text}.erb # outbound email chrome (unused — Supabase sends mail)
└── supabase/rails/ # gem view overrides — see below
├── sessions/new.html.erb # sign-in (email + password, GitHub OAuth)
├── registrations/new.html.erb # sign-up
├── passwords/{new,edit}.html.erb # request reset + new password form
├── otp/{new,verify}.html.erb # magic-link / OTP request + verify
├── oauth/_buttons.html.erb # "Continue with GitHub" partial
└── shared/_flash.html.erb # flash rendering shared across the gem's viewsapp/views/supabase/rails/ is how the gem's view overrides work: Rails resolves Supabase::Rails::SessionsController#new against this kit's app/views/supabase/rails/sessions/new.html.erb before falling back to the gem's bundled copy. Same shape as inertia_rails view inheritance — the controllers live in the gem, the views live in your app and can use your own components.
layouts/application.html.erb is the dashboard chrome. The whole template is gated on Current.user.present? — signed-out visitors of public routes (e.g. the / variant of HomeController#index) get a stripped-down container layout with no sidebar.
layouts/_head.html.erb is worth knowing about: it inlines a theme-init script that reads localStorage["theme"] and toggles the dark class on <html> before paint (no flash of wrong theme), pulls Importmap pins, loads the Railsblocks third-party CSS/JS via CDN (Shoelace, Tom-Select, Air Datepicker, PhotoSwipe), and emits the Tailwind stylesheet built by tailwindcss-rails.
app/javascript/
app/javascript/
├── application.js # entrypoint — imports turbo-rails + controllers
└── controllers/
├── application.js # the Stimulus Application instance
├── index.js # eager-loads all *_controller.js in this dir
└── hello_controller.js # placeholder — delete it or replace itThe Stimulus setup is the Rails default: eagerLoadControllersFrom("controllers", application) auto-registers any app/javascript/controllers/*_controller.js as a Stimulus controller whose tag matches the filename (hello_controller.js → data-controller="hello").
The application layout already references one controller you won't find on disk: data-controller="sidebar" opens / closes the mobile sidebar. That's a small Stimulus controller you'll add yourself when you customise the layout — the kit currently relies on the Hotwire defaults and basic CSS toggle classes for the mobile breakpoint. See Customization → Add a Stimulus controller for the canonical pattern.
app/models/
app/models/
├── application_record.rb # ActiveRecord::Base
├── concerns/ # empty — yours to populate
└── current.rb # ActiveSupport::CurrentAttributesCurrent declares :user and :session attributes. The Supabase::Rails::Authentication concern populates both on every authenticated request — Current.user is the value object built from the verified Supabase claims, Current.session is the encrypted-cookie session record.
There is no User ActiveRecord model in the kit. When you add your first AR model, generate it as usual (bin/rails g model …). If you later need a host-app users table that joins to Current.user.id, run bin/rails generate supabase:user_model.
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.
config/
config/application.rb
Standard Rails 8.1 application config with config.load_defaults 8.1. No custom middleware insertion (compare with the API starter, which inserts two custom middlewares around Supabase::Rails::Middleware).
config/routes.rb
supabase_authentication_routes # gem-provided: /session, /registration, /passwords, /otp, /oauth
get "sign_in", to: redirect("/session/new"), as: :sign_in
get "sign_up", to: redirect("/registration/new"), as: :sign_up
get "welcome", to: "pages#welcome", as: :welcome
get "dashboard", to: "home#index", as: :dashboard
resources :notes, only: %i[index update destroy]
namespace :settings do
resource :profile, only: %i[show update destroy], controller: "profiles"
resource :appearance, only: %i[show], controller: "appearances"
end
get "up" => "rails/health#show", as: :rails_health_check
root "home#index"supabase_authentication_routes expands to the Supabase-side sign-in/sign-up/OTP/OAuth/password-reset route table from supabase-rails. The kit's subclass controllers (in app/controllers/) take over these routes so they render with the kit's auth layout and components.
HomeController serves both / and /dashboard from #index — the controller-level allow_unauthenticated_access macro picks the right behaviour based on request.path.
config/initializers/supabase.rb
The highest-leverage file in config/. The non-default bits:
config.supabase.mode = :web— turns on the encrypted-cookie session machinery. The gem installsWeb::CookieCredentialStrategy, which mounts the session middleware that reads/writes thesb-sessioncookie.- Commented-out blocks for
allowed_redirect_origins,expose_current_user, andsessioncookie defaults. The:web-mode defaults are usually fine — seesupabase-railsreference for what they map to. - An
Rails.env.e2e?branch that bridges the Supabase CLI's environment-variable naming (SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY) onto what the gem reads (SUPABASE_PUBLISHABLE_KEY,SUPABASE_SECRET_KEY). This block only runs in thee2eRails environment.
config/importmap.rb
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
# Railsblocks JS dependencies — CDN-pinned
pin "@floating-ui/dom", to: "https://cdn.jsdelivr.net/...+esm"
pin "tom-select", to: "https://cdn.jsdelivr.net/...+esm"
# … (others — see the file)The first block is the Hotwire baseline that every Rails 8 app gets. The Railsblocks pins are CDN URLs — fine in development, but consider vendoring them with bin/importmap pin <pkg> --download before you ship to production.
config/database.yml
The kit runs on SQLite by default. Production declares four roles — primary, cache, queue, cable — each pointing at a different SQLite file under storage/. The Solid Cache/Queue/Cable schemas are loaded from db/cache_schema.rb, db/queue_schema.rb, and db/cable_schema.rb by bin/rails db:prepare.
The e2e Rails environment mirrors test — Supabase URLs and keys come from the environment, not from this file.
If you'd rather run on Postgres in production, swap the adapter in config/database.yml and add the pg gem to the Gemfile. The kit doesn't depend on SQLite-specific features.
config/deploy.yml
Kamal config — service: supabase_rails_starter, container image, registry, secret list, and a proxy block with SSL termination commented out. Edit before deploying. See Deployment for the production checklist.
supabase/
supabase/
├── config.toml # `supabase start` configuration
└── migrations/
├── 20260615040700_create_notes.sql # public.notes + RLS read/insert policies
└── 20260615041500_notes_write_policies.sql # RLS update/delete policiesThe checked-in supabase/ directory is what makes bin/e2e and supabase start work without a supabase init round-trip. config.toml configures the local CLI: the API URL on :54321, the database on :54322, Studio on :54323, and which schemas PostgREST exposes (public + graphql_public).
migrations/ is the canonical place for SQL that defines your application schema — RLS policies, tables, functions, RPC. They're applied automatically on supabase start against the local stack. For production, link the project (supabase link --project-ref <ref>) and run supabase db push, or paste the SQL into the dashboard.
The notes table is the minimal demonstration of:
- Owner column with
auth.uid()as the default — Postgres knows who the inserter is. enable row level securityplus four policies (read, insert, update, delete), all keyed onauth.uid() = user_id.- The PostgREST-friendly shape that
NotesControllerreads throughcurrent_supabase_client.from("notes").
db/
db/
├── seeds.rb # empty — no domain seeds
├── cable_schema.rb # Solid Cable
├── cache_schema.rb # Solid Cache
└── queue_schema.rb # Solid QueueNo migrate/ directory and no schema.rb because the kit has no domain ActiveRecord models — notes lives in Supabase Postgres, not in the Rails-owned SQLite. The three *_schema.rb files are loaded into the matching SQLite databases declared in config/database.yml by bin/rails db:prepare.
When you add your first domain model that you want in the Rails-owned DB, Rails will generate db/migrate/ and db/schema.rb as usual.
test/
test/
├── test_helper.rb # fake SUPABASE_* + SupabaseAuthStubs prepend
├── application_system_test_case.rb # Capybara + headless Chrome at 1400×1400
├── controllers/ # ActionController::TestCase
├── models/
├── helpers/
├── mailers/
├── integration/
├── system/ # full-stack browser tests
├── fixtures/
├── support/
│ └── supabase_auth_stubs.rb # in-process auth bypass for non-e2e tests
└── e2e/
├── README.md # author's guide for new e2e tests
├── e2e_test_case.rb # base class — sets up real Supabase reset
├── smoke_test.rb # registration → dashboard
├── sign_{up,in,out}_flow_test.rb # happy + negative paths
├── session_persistence_flow_test.rb # multi-request session + JWT claim
├── session_expiry_flow_test.rb # expired-session redirect + flash
└── rls_unauthorized_{reads,writes}_flow_test.rb # cross-user RLS checksTwo test stacks live side by side. The regular test/controllers/, test/system/, etc. run against the stubbed Supabase Auth — SupabaseAuthStubs (under test/support/) is prepended into ApplicationController and short-circuits the gem's middleware so tests can sign in with sign_in_as(...) without an HTTP round-trip to Supabase. Fast, hermetic, and the default suite (bin/rails test, bin/rails test:system).
test/e2e/ runs against a live local Supabase stack booted by bin/e2e. The base class (E2ETestCase) inherits from ApplicationSystemTestCase, calls SupabaseReset.clean! between tests (drains auth.users + your registered tables), and exposes sign_up_as / sign_in_as helpers that drive real forms. Use this suite to catch regressions in the cookie session lifecycle, RLS enforcement, and the OAuth round-trip — the things stubs can't validate.
Other directories
| Directory | What it is | Touch it when |
|---|---|---|
bin/ | Rails 8 bin stubs + bin/ci, bin/dev, bin/e2e, bin/setup, bin/kamal, bin/rubocop, bin/brakeman, bin/bundler-audit | Running quality gates, the dev server, or Kamal commands |
lib/ | Empty lib/tasks/ — for rake tasks you add | You add a rake task |
public/ | Default Rails 8 error pages + icon.png/icon.svg | You change error page styling or the favicon |
storage/ | SQLite database files + Active Storage blobs | 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 Hotwire starter, install dependencies, point it at a Supabase project, and sign in for the first time.
Architecture
How supabase-rails plugs into the Hotwire starter — the cookie session lifecycle, the Turbo + Stimulus surfaces, the per-request RLS-scoped Supabase client, and how the supabase/ directory is used.