supabase-rb-rb
Hotwire starter

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 overrides

ApplicationController inherits from ActionController::Base and includes the Authentication concern from concerns/. The concern bundles two things:

  1. include Supabase::Rails::Authentication — installs before_action :require_authentication on every action, exposes Current.user, and wires up the allow_unauthenticated_access macro.
  2. prepend ExpiredSessionFlash — an override of request_authentication that attaches a "Your session has expired" flash before redirecting to sign-in, but only when the request arrived with an sb-session cookie that the middleware just had to invalidate (expired refresh token, tampered ciphertext). The prepend is what makes it win method-resolution against the gem's own request_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-right

ViewComponent 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 views

app/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 it

The 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.jsdata-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::CurrentAttributes

Current 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 installs Web::CookieCredentialStrategy, which mounts the session middleware that reads/writes the sb-session cookie.
  • Commented-out blocks for allowed_redirect_origins, expose_current_user, and session cookie defaults. The :web-mode defaults are usually fine — see supabase-rails reference 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 the e2e Rails 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 policies

The 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 security plus four policies (read, insert, update, delete), all keyed on auth.uid() = user_id.
  • The PostgREST-friendly shape that NotesController reads through current_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 Queue

No 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 checks

Two 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

DirectoryWhat it isTouch it when
bin/Rails 8 bin stubs + bin/ci, bin/dev, bin/e2e, bin/setup, bin/kamal, bin/rubocop, bin/brakeman, bin/bundler-auditRunning quality gates, the dev server, or Kamal commands
lib/Empty lib/tasks/ — for rake tasks you addYou add a rake task
public/Default Rails 8 error pages + icon.png/icon.svgYou change error page styling or the favicon
storage/SQLite database files + Active Storage blobsNever 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