supabase-rb-rb
Rails API starter

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
end

The 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
end

3. 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
end

Current.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
end

auth_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
end

Then regenerate swagger/v1/swagger.yaml:

RAILS_ENV=test bundle exec rake rswag:specs:swaggerize

Commit 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
end

Two 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]
  ...
end

Current.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"
end

For 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
end

This 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:migrate

This 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: false

And 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:migrate

Or 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.

On this page