supabase-rb-rb
Rails API starter

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 claims

ApplicationController 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.rb

A 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::CurrentAttributes

Current 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:
    1. starter_kit.json_unauthorized_responder — inserts JsonUnauthorizedResponder before Supabase::Rails::Middleware (outer side), so it sees the gem's 401s on the way out.
    2. starter_kit.rack_attack — moves Rack::Attack ahead of Supabase::Rails::Middleware so 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.rb

supabase.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, or SUPABASE_JWT_SECRET is missing.
  • Maps SUPABASE_ANON_KEY onto the gem's expected SUPABASE_PUBLISHABLE_KEY so 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 Queue

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

The 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.yaml

The 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

DirectoryWhat it isTouch it when
bin/Rails 8 bin stubs + bin/ci, bin/kamal, bin/rubocop, bin/brakeman, bin/bundler-auditRunning quality gates or Kamal commands
lib/Empty lib/tasks/ — for rake tasks you addYou add a rake task
public/Default Rails 8 error pagesYou change error page styling
storage/, tmp/, log/Rails runtime dataNever directly
vendor/Vendored dependencies (rare)You vendor a gem
test/Minitest suite for CORS + Rack::AttackYou break those middlewares
script/Project scriptsYou add a one-off script

On this page