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.
The starter is a Rails 8.1 monolith — most of the auth machinery lives in supabase-rails in :web mode. This page covers the integration points the kit relies on so you can reason about (and safely modify) the request lifecycle.
High-level shape
Browser ─► Rails (Hotwire HTML)
│ │
│ ├─ Supabase::Rails::Middleware
│ │ ↳ reads encrypted sb-session cookie
│ │ ↳ refreshes Supabase access token if needed
│ │ ↳ populates Current.user + request.env[CONTEXT_KEY]
│ │
│ └─ Controller ─► per-request supabase client (RLS-scoped)
│ │
│ ▼
│ Supabase Postgres (PostgREST)
│
└─► Supabase Auth (sign-up, OAuth, password reset)
↳ redirects back through Rails so the gem can mint the cookieThe browser never holds a token. The kit holds the encrypted sb-session cookie; the cookie contains the Supabase access token, which the gem unwraps server-side and uses to make per-request, RLS-scoped Supabase calls.
Cookie session lifecycle
The Supabase::Rails::Middleware (installed by config.supabase.mode = :web) participates in every request, even unauthenticated ones:
- On request in. The middleware reads the encrypted
sb-sessioncookie. If present and decryptable, it loads the stored Supabase access + refresh tokens, checks expiry, and refreshes the access token if needed (asking the Supabase token endpoint for a new pair). - Populates
Current. Verified claims become aSupabase::Rails::Uservalue object onCurrent.user. The session itself goes onCurrent.session. A per-requestSupabase::Client(itsAuthorizationheader set to the unwrapped access token) is stashed inrequest.env[Supabase::Rails::CONTEXT_KEY]for controllers to use. - On the way out. If the access token was refreshed mid-request, the middleware rewrites the
sb-sessioncookie with the new ciphertext. The cookie isHttpOnly,SameSite=Lax, andSecurewhen behind TLS.
If decryption fails (key rotation, tampered cookie) or the refresh round-trip fails (revoked session, expired refresh token), the middleware drops Current.user to nil. The Authentication concern's request_authentication then redirects to /session/new — and the ExpiredSessionFlash override in app/controllers/concerns/authentication.rb attaches a "Your session has expired" flash, but only when there was a cookie on the request (so a fresh visitor never sees the flash).
The kit's SessionsController#destroy calls terminate_session (a gem helper that clears the sb-session cookie and Current.*) and redirects to /welcome rather than /, so a signed-out user lands on an explicit public landing page with log-in / register CTAs.
Per-request Supabase client
The gem doesn't just verify the user — it also gives the controller a Supabase client preconfigured with that user's access token. The pattern is used directly by NotesController:
class NotesController < ApplicationController
def index
response = current_supabase_client.from("notes").select("id,content,created_at").execute
@notes = response.data || []
end
private
def current_supabase_client
request.env[Supabase::Rails::CONTEXT_KEY].supabase
end
endThe client returned by request.env[Supabase::Rails::CONTEXT_KEY].supabase carries an Authorization: Bearer <user_access_token> header. Every PostgREST call made through that client is RLS-scoped to Current.user — Postgres sees the request as coming from that user (because auth.uid() in policies is read from the JWT), and the row-level policies on public.notes filter reads, inserts, updates, and deletes accordingly.
This is the invariant the kit's e2e tests verify: two browsers signed in as different users, hitting the same /notes index, see disjoint lists; an update against another user's row returns zero affected rows ("Note not found"); a delete likewise short-circuits.
Why route writes through PostgREST instead of ActiveRecord
NotesController#update could have used ActiveRecord with a Postgres adapter pointed at Supabase. The kit chooses PostgREST through the per-request client because:
- RLS works automatically. AR runs against a Postgres role that bypasses RLS (or that you'd have to set per-request with
SET LOCAL "request.jwt.claims"). PostgREST embeds the JWT as the request identity, so policies fire. - No shadow schema. No
db/migrate/, nodb/schema.rb, no migration drift between Rails and Supabase. Supabase Postgres is the source of truth;supabase/migrations/is the migration toolchain. - Same wire path as the rest of Supabase. Realtime, Storage, and the JS client all hit PostgREST — keeping your Rails app on the same path means RLS policies are written once.
When you need full ActiveRecord features (joins, belongs_to, eager loading) for a domain table, the kit doesn't force a choice: add a Postgres connection to config/database.yml keyed on the Rails service role, or generate bin/rails generate supabase:user_model for the join pattern.
Hotwire integration
The Hotwire pieces are wired in the standard Rails 8 way — turbo-rails and stimulus-rails in the Gemfile, Importmap pins in config/importmap.rb, and app/javascript/application.js importing @hotwired/turbo-rails plus the Stimulus controller bundle.
Turbo Drive
Turbo Drive intercepts every <a> and <form> and swaps the <body> over an XHR fetch. The kit's only deliberate interaction with Turbo Drive is stale_when_importmap_changes in ApplicationController, which busts the HTML etag whenever an Importmap pin changes — so the browser pulls a fresh shell after a deploy that touches importmap.rb.
Turbo Frames and Streams
The kit's views don't use Frames or Streams yet — the Notes view is a single-page list. They're available the moment you reach for them: every controller can render turbo_stream:, and <turbo-frame> elements work out of the box. Recipe 1 in Customization walks through adding an inline-edit Frame to notes#index.
Stimulus
Stimulus is loaded via the eager controller registration pattern. app/javascript/controllers/index.js calls eagerLoadControllersFrom("controllers", application) so any new *_controller.js file is auto-registered with a tag matching the filename — drop app/javascript/controllers/realtime_notes_controller.js and data-controller="realtime-notes" works.
The shipping hello_controller.js is a Rails default — feel free to delete it. The application layout already references data-controller="sidebar"; you supply the matching sidebar_controller.js when you customise the mobile chrome (or rely on CSS-only toggling and remove the data-controller attribute).
Supabase integration points
The starter wires up 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 thesb-sessioncookie and manages refresh.Supabase::Rails::SessionStore— the encrypted-cookie session store backing the strategy.- The full
supabase_authentication_routestable — sessions, registrations, passwords, OTP, OAuth.
If you ever want JWT-only auth with no cookies, switch to :api mode — but the Rails API starter is built around it and is the easier starting point.
2. Auth strategies
The default :web-mode strategy chain (cookie → none) is what the kit relies on. allow_unauthenticated_access whitelists actions; everything else demands a session. HomeController#index is the example to study — allow_unauthenticated_access only: :index, unless: -> { request.path == dashboard_path } says "let the public / variant through, but require auth when the same action is mounted at /dashboard."
3. View overrides
supabase-rails ships default ERB views for sign-in, sign-up, password reset, OTP, and the OAuth buttons. The kit overrides every one of them in app/views/supabase/rails/. Rails view inheritance resolves the kit's copy first, so the gem's defaults never render — you get Tailwind, ViewComponent, and Railsblocks-styled forms instead.
When you upgrade the gem, the gem's view changes don't auto-merge into your overrides. Diff the gem's supabase/rails/sessions/new.html.erb against yours and port any new fields by hand.
4. Account updates
Settings::ProfilesController#update calls supabase_update_user(...) — a controller-side helper exposed by the gem that wraps Supabase's auth.updateUser endpoint. The same machinery handles email + display-name changes. It returns a Result object the controller pattern-matches against to render the flash. No AR User model means no params.permit :email, :display_name against a Rails model — the form posts directly to Supabase Auth via the gem.
What lives outside the kit
A few things look like they belong to the starter but are actually contracts owned elsewhere:
- Email delivery. Supabase Auth sends confirmation, password-reset, magic-link, and OAuth completion emails. The kit's
app/mailers/is empty. In development, the kit shipsletter_opener_webon/letter_openerso you can read what Supabase would have sent (when configured to use the dev SMTP relay). - The user record. Supabase Auth owns
auth.users. The kit doesn't have auserstable.Current.user.idis the Supabase UUID — use it as the foreign key for your own tables (see Recipe 1 in Customization). - OAuth providers. GitHub OAuth is configured in the Supabase dashboard, not in Rails. The "Continue with GitHub" button on
sessions/newandregistrations/newjust kicks off the Supabase OAuth flow. - The session token format. Supabase mints the access/refresh JWTs. The kit handles the cookie wrapping but not the signing —
SUPABASE_JWT_SECRET(if you swap to API-style JWT verification later) is the secret you share with Supabase.
Why no User model
Same trade-off as the API kit:
- No AR
User— what the kit does.Current.useris the verified session. Zero local user state, zero sync drift between Supabase and Rails. Domain models foreign-key directly toCurrent.user.id(a UUID). - AR
User— whatbin/rails generate supabase:user_modelgives you. Auserstable keyed byid = Current.user.id, populated lazily or by webhook. You getbelongs_to :user, eager loading, reflections — at the cost of keeping the table in sync with Supabase Auth.
The kit ships without it because most Hotwire 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.
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.
Customization
Hotwire-flavored recipes for extending the starter — inline Turbo Frame editing, a Stimulus controller wired to Supabase Realtime, and a new ViewComponent in the dashboard chrome.