Customization
Concrete recipes for extending the Rails API starter — add a resource, allow an unauthenticated route, change the database schema.
The kit is small on purpose. These three recipes cover the extensions you'll reach for first.
Recipe 1 — Add a resource
Let's add a notes resource scoped to the authenticated user. By the end you'll have GET /api/v1/notes, POST /api/v1/notes, real specs, and an OpenAPI entry.
1. Migration and model
bin/rails generate model Note user_id:uuid:index title:string body:text
bin/rails db:migrate# app/models/note.rb
class Note < ApplicationRecord
validates :title, presence: true
endThe user_id column is a UUID, not a belongs_to. The kit has no User AR model — the foreign key points at Current.user.id, which is the verified sub claim from the JWT. If you want a real belongs_to :user, run bin/rails generate supabase:user_model first and switch the column to references.
2. Routes
# config/routes.rb
namespace :api do
namespace :v1 do
get "me", to: "me#show"
resources :notes, only: %i[index create]
end
end3. Controller
# app/controllers/api/v1/notes_controller.rb
module Api
module V1
class NotesController < ApplicationController
def index
notes = Note.where(user_id: Current.user.id).order(created_at: :desc)
render json: notes
end
def create
note = Note.new(note_params.merge(user_id: Current.user.id))
if note.save
render json: note, status: :created
else
render json: { errors: note.errors }, status: :unprocessable_entity
end
end
private
def note_params
params.expect(note: %i[title body])
end
end
end
endCurrent.user is the verified JWT — already authenticated by the time the controller runs, because Authentication is included in ApplicationController.
4. Request specs
# spec/requests/notes_spec.rb
require "rails_helper"
RSpec.describe "Api::V1::Notes" do
describe "GET /api/v1/notes" do
it "returns the caller's notes" do
user_id = "11111111-1111-1111-1111-111111111111"
Note.create!(user_id: user_id, title: "Mine")
Note.create!(user_id: "22222222-2222-2222-2222-222222222222", title: "Theirs")
get "/api/v1/notes", headers: auth_headers(sub: user_id)
expect(response).to have_http_status(:ok)
titles = response.parsed_body.map { |n| n["title"] }
expect(titles).to eq(["Mine"])
end
it "401s without a token" do
get "/api/v1/notes"
expect(response).to have_http_status(:unauthorized)
end
end
endauth_headers(sub: …) is from SupabaseAuthHelper — it mints a real signed JWT against the in-memory JWKS, so the request goes through the actual middleware stack.
5. OpenAPI
Add the integration spec so the docs regenerate to include the new endpoints:
# spec/integration/notes_spec.rb
require "swagger_helper"
RSpec.describe "Notes", type: :request do
path "/api/v1/notes" do
get "List notes for the authenticated user" do
tags "Notes"
produces "application/json"
security [ { bearer_auth: [] } ]
response "200", "ok" do
schema type: :array,
items: { type: :object,
required: %w[id title],
properties: { id: { type: :integer },
title: { type: :string },
body: { type: :string } } }
let(:Authorization) { auth_headers["Authorization"] }
run_test!
end
end
end
endThen regenerate swagger/v1/swagger.yaml:
RAILS_ENV=test bundle exec rake rswag:specs:swaggerizeCommit the regenerated YAML — it's the file production serves.
Recipe 2 — Allow a public route past auth
The Authentication concern installs before_action :require_authentication on every action. To exempt one, use the allow_unauthenticated_access class macro the gem provides. HealthzController is the example already in the kit:
# app/controllers/healthz_controller.rb
class HealthzController < ApplicationController
allow_unauthenticated_access only: :show
def show
render json: { status: "ok" }
end
endTwo common variations:
# All actions on this controller are public.
class StatusController < ApplicationController
allow_unauthenticated_access
...
end
# Mix public and authenticated actions on the same controller.
class PostsController < ApplicationController
allow_unauthenticated_access only: %i[index show]
...
endCurrent.user is nil on the public actions, so guard any code that depends on it. Read Authentication → allow_unauthenticated_access for the full semantics (including how it interacts with the :user/:none strategy chain).
Allow a route through the rate limiter too
If a public route should also bypass Rack::Attack, exclude it from the throttles. The kit's throttles only fire on paths under /api/, so a top-level /healthz is automatically out. If you put a public route under /api/, narrow the throttle:
# config/initializers/rack_attack.rb
Rack::Attack.throttle("api/ip", limit: 300, period: 5.minutes) do |req|
req.ip if req.path.start_with?("/api/") && req.path != "/api/v1/status"
endFor a public route under /api/, also consider whether CORS preflight (OPTIONS) needs to pass — the existing CORS config already allows OPTIONS, you just need to make sure CORS_ORIGINS is set in the environment.
Recipe 3 — Change the database schema
The kit ships with no domain migrations, so your first schema change is your first migration. The interesting question is what column type to use for the foreign key to auth.users — Supabase user ids are UUIDs.
Two patterns
A. Reference Current.user.id directly (no AR User).
class CreateNotes < ActiveRecord::Migration[8.1]
def change
create_table :notes do |t|
t.uuid :user_id, null: false, index: true
t.string :title, null: false
t.text :body
t.timestamps
end
end
endThis is what Recipe 1 above does. Pros: no AR User, no sync work, no migration when a user signs up. Cons: no belongs_to :user, so no eager loading, no validations, no reflections.
B. Add a host-app User model and belongs_to.
bin/rails generate supabase:user_model
bin/rails db:migrateThis creates a users table keyed by id = Current.user.id and a User AR model. From there, your domain migrations can use references:
t.references :user, type: :uuid, foreign_key: true, null: falseAnd your models can belongs_to :user. Trade-off: you now have to populate that users row before the FK constraint will allow a write. The generator's docs cover the options (lazy creation in Current.user, webhook from Supabase Auth, etc.).
Multiple databases
The kit configures four Postgres databases in production: primary, cache (Solid Cache), queue (Solid Queue), and cable (Solid Cable). Your migrations go in the default db/migrate/ and target primary. The Solid * schemas are loaded from db/{cache,queue,cable}_schema.rb by bin/rails db:prepare — leave them alone unless you have a reason.
If you don't need three separate databases, point all four roles at the same connection in config/database.yml — Rails 8 supports it, and it's a fine simplification for small apps.
Migrating in production with Kamal
Run migrations as part of the deploy. The Kamal config doesn't ship a hook for this — add one to config/deploy.yml:
# config/deploy.yml
servers:
web:
hosts:
- HETZNER_HOST
options:
pre-deploy: |
bundle exec rails db:migrateOr use kamal app exec:
bin/kamal app exec --interactive --reuse "bin/rails db:migrate"Either way, migrations should be backwards-compatible with the previous deploy until the new image is live — the rolling-deploy rules from the Rails guides apply here too.
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.
Deployment
Production-readiness checklist for the Rails API starter — secrets, TLS, rate-limit cache, health checks, observability.