Project structure
A directory-by-directory walkthrough of the Rails API starter — app/, config/, db/, spec/, and swagger/ — at the level of detail a new contributor needs.
The kit is a stock Rails 8 API-only 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. The rest of app/ (jobs/, mailers/, views/layouts/mailer.*) is the Rails generator default and can be ignored until you need a job or a mailer.
app/controllers/
app/controllers/
├── application_controller.rb # ActionController::API + Authentication
├── healthz_controller.rb # public liveness probe
├── concerns/
│ └── authentication.rb # wraps Supabase::Rails::Authentication
└── api/
└── v1/
└── me_controller.rb # GET /api/v1/me — returns verified claimsApplicationController inherits from ActionController::API (no views, no cookies, no CSRF) and includes the Authentication concern from the same directory. The concern simply wraps Supabase::Rails::Authentication and overrides request_authentication so a missing token returns the canonical {"error":"unauthorized"} JSON body instead of the gem's default head :unauthorized.
You'll also see otp_controller.rb, passwords_controller.rb, sessions_controller.rb, registrations_controller.rb, and oauth_controller.rb in app/controllers/. These are scaffolded by supabase-rails' install generator and are not wired into the kit's API routes — they're inert in :api mode. Leave them in place until you decide whether you want to expose any Supabase-side auth flows through Rails; delete them otherwise.
app/middleware/
app/middleware/
└── json_unauthorized_responder.rbA single-purpose Rack responder: any 401 from a downstream middleware (in particular Supabase::Rails::Middleware, which emits {message:, code:}) gets its body rewritten to {"error":"unauthorized"}. It's inserted just outside the Supabase middleware so it sees the response on the way out. See Architecture → Middleware stack for where it sits in the chain.
app/models/
app/models/
├── application_record.rb # ActiveRecord::Base
└── current.rb # ActiveSupport::CurrentAttributesCurrent declares :user and :session attributes. The Supabase::Rails::Authentication concern populates Current.user from the verified JWT claims on every request — 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 want a host-app users table that joins to Current.user.id, run bin/rails generate supabase:user_model — it ships a generator that writes the migration and reflection.
config/
Everything kit-specific lives in config/initializers/, config/routes.rb, and config/application.rb. The rest is Rails defaults.
config/application.rb
Two non-default bits worth knowing about:
config.api_only = true— the entire reason the cookie/CSRF/flash middleware isn't in the stack.- Two custom initializers, both
after: "supabase.middleware", manipulate the middleware stack:starter_kit.json_unauthorized_responder— insertsJsonUnauthorizedResponderbeforeSupabase::Rails::Middleware(outer side), so it sees the gem's 401s on the way out.starter_kit.rack_attack— movesRack::Attackahead ofSupabase::Rails::Middlewareso throttled requests short-circuit before JWT verification spends a JWKS lookup.
Architecture draws the resulting middleware order.
config/routes.rb
mount Rswag::Ui::Engine => "/api-docs" # dev/test always, prod when SWAGGER_UI_ENABLED=true
mount Rswag::Api::Engine => "/api-docs"
supabase_authentication_routes # from supabase-rails — inert in :api mode
namespace :api do
namespace :v1 do
get "me", to: "me#show"
end
end
get "healthz", to: "healthz#show"
get "up" => "rails/health#show"supabase_authentication_routes expands to the Supabase-side sign-in/sign-up/OTP/OAuth route table from supabase-rails. It's harmless to keep; remove the line if you're certain you'll never expose those flows.
config/initializers/
config/initializers/
├── supabase.rb # mode = :api, auth strategies, env fail-fast
├── cors.rb # Rack::Cors driven by CORS_ORIGINS
├── rack_attack.rb # api/ip + api/token throttles
├── rswag_api.rb / rswag_ui.rb
├── filter_parameter_logging.rb
└── inflections.rbsupabase.rb is the highest-leverage file in config/. It sets:
config.supabase.mode = :api— disables cookie session machinery in the gem.config.supabase.auth = %i[user none]— try JWT auth first, fall through to anonymous so missing-token requests reach the controller and get the canonical 401 body.- A boot-time fail-fast in production if
SUPABASE_URL,SUPABASE_ANON_KEY, orSUPABASE_JWT_SECRETis missing. - Maps
SUPABASE_ANON_KEYonto the gem's expectedSUPABASE_PUBLISHABLE_KEYso the apikey middleware works without a second env var.
rack_attack.rb has two throttles — api/ip (300 req per 5 min) and api/token (1000 req per min) — both gated on req.path.start_with?("/api/"). The throttled responder returns {"error":"too_many_requests"} with a Retry-After header. Rack::Attack.enabled = false in test so request specs stay deterministic.
cors.rb reads CORS_ORIGINS, defaults to * in development/test, and is not mounted if the var is empty in production — fail-closed.
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 models. The three *_schema.rb files are loaded into the matching Postgres databases declared in config/database.yml (primary, cache, queue, cable) by bin/rails db:prepare.
When you add your first domain model, Rails will generate db/migrate/ and db/schema.rb (or structure.sql) as usual.
spec/
spec/
├── spec_helper.rb
├── rails_helper.rb # seeds SUPABASE_* test ENV + in-memory JWKS
├── swagger_helper.rb # rswag DSL config — drives swagger/v1/swagger.yaml
├── requests/ # plain RSpec request specs
│ ├── healthz_spec.rb
│ └── me_spec.rb
├── integration/ # rswag integration specs — also the OpenAPI source
│ ├── healthz_spec.rb
│ └── me_spec.rb
└── support/
└── supabase_auth_helper.rb # mints HS256 JWTs against the in-memory JWKSThe thing to internalise: spec/integration/ is dual-purpose. The DSL there describes behaviour for RSpec and serializes to swagger/v1/swagger.yaml when you run rake rswag:specs:swaggerize. If you change a request/response shape, update the matching integration spec — the docs follow automatically.
spec/requests/ is plain RSpec request specs — same coverage, no DSL overhead. Use this style for the high-cardinality coverage you don't need in the OpenAPI doc (error edge cases, etc.).
spec/support/supabase_auth_helper.rb exposes auth_headers(claims = {}) to request specs. The companion in-memory JWKS keyed by SUPABASE_JWT_SECRET (configured in rails_helper.rb) means tokens minted in tests round-trip through real middleware — no stubbing.
The kit also has a Minitest tree under test/ covering CORS and Rack::Attack at the rack level. New work should go in spec/; the Minitest suite is kept around to exercise infrastructure that's awkward to drive from request specs.
swagger/
swagger/
└── v1/
└── swagger.yamlThe OpenAPI 3 document served by rswag-ui at /api-docs. It's checked in so production can ship the docs without running the test suite at boot. Regenerate it with:
RAILS_ENV=test bundle exec rake rswag:specs:swaggerize(RAILS_ENV=test is required — rswag-specs is in the :test Bundler group, so the swaggerize task is only registered then.)
swagger_helper.rb defines the top-level OpenAPI metadata: title, description, server URL template, and the bearer_auth security scheme. Edit it if you need to add another security scheme, change the server URL convention, or version the document.
Other directories
| Directory | What it is | Touch it when |
|---|---|---|
bin/ | Rails 8 bin stubs + bin/ci, bin/kamal, bin/rubocop, bin/brakeman, bin/bundler-audit | Running quality gates or Kamal commands |
lib/ | Empty lib/tasks/ — for rake tasks you add | You add a rake task |
public/ | Default Rails 8 error pages | You change error page styling |
storage/, tmp/, log/ | Rails runtime data | Never directly |
vendor/ | Vendored dependencies (rare) | You vendor a gem |
test/ | Minitest suite for CORS + Rack::Attack | You break those middlewares |
script/ | Project scripts | You add a one-off script |
Getting started
Clone the Rails API starter, install dependencies, point it at a Supabase project, and make your first authenticated request.
Architecture
How supabase-rails plugs into the Rails API starter — the middleware stack, the request lifecycle, JWT verification against the JWKS, and how Current.user gets populated.