Skip to main content
In this tutorial, we’ll expose our Brand model through Spree’s v3 API — the customer-facing Store API that storefronts read from, and the back-office Admin API with full CRUD for apps and integrations. We’ll also extend the existing Product serializer to include brand data.
This guide assumes you’ve completed the Model, Admin, and Extending Core Models tutorials.

What We’re Building

By the end of this tutorial, you’ll have:
  • GET /api/v3/store/brands and GET /api/v3/store/brands/:id — customer-facing, read-only, lookup by prefixed ID or slug
  • Full CRUD on /api/v3/admin/brands — for back-office apps and integrations
  • Brand data included in Product responses via ?expand=brand
  • Understanding of how to add new API endpoints and extend existing serializers

The Fast Path: One Generator Command

Everything this page builds by hand can be generated in one command with spree:api_resource:
spree generate api_resource Brand
Because the Brand model already exists, the generator leaves it (and its migration) untouched and produces only the API surface — no conflict prompts, no overwrites. Your model is “owned once”: after creation, domain code belongs to you, and the generator only ever adds API files around it. You’ll see this in the output:
      skip  model app/models/spree/brand.rb (owned-once; already exists)
      skip  migration (model already exists; add a new migration for schema changes)
    create  app/controllers/spree/api/v3/store/brands_controller.rb
    create  app/controllers/spree/api/v3/admin/brands_controller.rb
    create  app/serializers/spree/api/v3/brand_serializer.rb
    create  app/serializers/spree/api/v3/admin/brand_serializer.rb
    create  spec/factories/spree/brand_factory.rb
For a brand-new resource you’d pass the attributes too (spree generate api_resource Brand name:string:uniq) and get the model and migration in the same run. If you just want a working API, run the generator and skip ahead to Step 5: Test the Endpoints. The rest of this page builds the Store side by hand so you understand what the generator produces and how to customize it — the Admin API section then shows how little the back-office surface adds on top.
Using an AI agent? The Spree agent skills include a dedicated resource-generator skill — your agent knows the field syntax, the flags, and the generated-file contract.

How the Store API Works

Every Store API endpoint follows the same pattern:
  1. Controller inherits from Spree::Api::V3::Store::ResourceController which provides CRUD, pagination, Ransack filtering, and authorization out of the box
  2. Serializer inherits from Spree::Api::V3::BaseSerializer (uses Alba) and defines which fields to return
  3. Routes are added via Spree::Core::Engine.add_routes
  4. Serializer registration via Spree::Api::Dependencies enables dependency injection so serializers can be swapped by extensions or the host app

Step 1: Prepare the Brand Model for the API

Store API requires two things from models:
  1. Prefixed IDs — Stripe-style IDs like brand_k5nR8xLq instead of raw database IDs. The spree:model generator already added has_prefix_id :brand in the Model step, so this is done.
  2. Slugs — human-readable URL identifiers like nike for GET /brands/nike
Add a slug column:
spree generate migration AddSlugToSpreeBrands slug:string:uniq
spree migrate
Then add FriendlyId to the Brand model:
app/models/spree/brand.rb
module Spree
  class Brand < Spree.base_class
    extend FriendlyId

    has_prefix_id :brand
    friendly_id :slug_candidates, use: [:slugged, :scoped], scope: spree_base_uniqueness_scope

    has_many :products, class_name: 'Spree::Product', dependent: :nullify

    has_rich_text :description
    has_one_attached :logo

    validates :name, presence: true

    self.whitelisted_ransackable_attributes = %w[name]
    self.whitelisted_ransackable_associations = %w[]
    self.whitelisted_ransackable_scopes = %w[]
  end
end
Now:
  • Spree::Brand.first.prefixed_id returns brand_k5nR8xLq
  • Spree::Brand.find_by_prefix_id!('brand_k5nR8xLq') finds by prefixed ID
  • Spree::Brand.friendly.find('nike') finds by slug
  • Slugs are auto-generated from the name via slug_candidates (inherited from the Spree base class)

Step 2: Create the Serializer

Create a serializer that defines the JSON response shape for brands:
app/serializers/spree/api/v3/brand_serializer.rb
module Spree
  module Api
    module V3
      class BrandSerializer < BaseSerializer
        typelize name: :string,
                 slug: [:string, nullable: true],
                 description: [:string, nullable: true],
                 logo_url: [:string, nullable: true]

        attributes :name, :slug

        attribute :description do |brand|
          brand.description&.to_plain_text
        end

        attribute :logo_url do |brand|
          image_url_for(brand.logo) if brand.logo.attached?
        end
      end
    end
  end
end

Understanding the Serializer

  • BaseSerializer automatically converts id to a prefixed ID and provides context helpers (current_store, current_currency, etc.)
  • typelize provides type hints used by Typelizer to auto-generate TypeScript types for the SDK
  • attributes lists database columns to include directly
  • attribute ... do blocks define computed fields (like stripping HTML from rich text, or generating image URLs)

Step 3: Create the Controller

Create a controller that inherits from Store::ResourceController:
app/controllers/spree/api/v3/store/brands_controller.rb
module Spree
  module Api
    module V3
      module Store
        class BrandsController < ResourceController
          protected

          def model_class
            Spree::Brand
          end

          def serializer_class
            Spree::Api::V3::BrandSerializer
          end

          def scope
            Spree::Brand.all
          end
        end
      end
    end
  end
end

Understanding the Controller

ResourceController gives you index and show actions automatically. You only need to define:
MethodPurpose
model_classWhich ActiveRecord model to query
serializer_classWhich serializer to render responses with
scopeBase query scope (add .where(...) to filter)
The base controller handles:
  • Pagination via Pagy (?page=2&limit=25)
  • Filtering via Ransack (?q[name_cont]=nike)
  • Sorting via JSON:API style (?sort=-name for descending)
  • Authorization via CanCanCan
  • Prefixed ID lookup for show action (/brands/brand_k5nR8xLq)
For core models, controllers use Spree.api.product_serializer which looks up the serializer from Spree::Api::Dependencies. This allows extensions to swap the serializer. For your own custom models, reference the serializer class directly — the dependency system only supports core injection points.

Adding Slug Lookup

To also support fetching brands by slug (like products support /products/blue-t-shirt), override find_resource:
app/controllers/spree/api/v3/store/brands_controller.rb
module Spree
  module Api
    module V3
      module Store
        class BrandsController < ResourceController
          protected

          def model_class
            Spree::Brand
          end

          def find_resource
            id = params[:id]
            if id.to_s.start_with?('brand_')
              scope.find_by_prefix_id!(id)
            else
              scope.friendly.find(id)
            end
          end

          def serializer_class
            Spree::Api::V3::BrandSerializer
          end

          def scope
            Spree::Brand.all
          end
        end
      end
    end
  end
end

Step 4: Add Routes

Add the routes for your new endpoints:
config/routes.rb
Spree::Core::Engine.add_routes do
  namespace :api, defaults: { format: 'json' } do
    namespace :v3 do
      namespace :store do
        resources :brands, only: [:index, :show]
      end
    end
  end
end
This creates:
  • GET /api/v3/store/brands — paginated list with filtering/sorting
  • GET /api/v3/store/brands/:id — single brand by prefixed ID or slug

Step 5: Test the Endpoints

Restart your server and test:
# List brands
curl -H "X-Spree-API-Key: pk_YOUR_KEY" \
     http://localhost:3000/api/v3/store/brands

# Get a single brand
curl -H "X-Spree-API-Key: pk_YOUR_KEY" \
     http://localhost:3000/api/v3/store/brands/brand_k5nR8xLq

# Filter by name
curl -H "X-Spree-API-Key: pk_YOUR_KEY" \
     "http://localhost:3000/api/v3/store/brands?q[name_cont]=nike"

# Sort alphabetically
curl -H "X-Spree-API-Key: pk_YOUR_KEY" \
     "http://localhost:3000/api/v3/store/brands?sort=name"

Response Format

List response:
{
  "data": [
    {
      "id": "brand_k5nR8xLq",
      "name": "Nike",
      "slug": "nike",
      "description": "Just Do It",
      "logo_url": "https://cdn.example.com/brands/nike-logo.png"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 25,
    "count": 42,
    "pages": 2,
    "from": 1,
    "to": 25,
    "in": 25,
    "previous": null,
    "next": 2
  }
}

The Admin API

The Admin API is the other half of v3 — same protocol, same serializer/controller patterns, but authenticated with secret keys (sk_*) or admin JWTs, and full CRUD by default. The spree:api_resource generator produces both pieces; here’s what they look like:
app/serializers/spree/api/v3/admin/brand_serializer.rb
module Spree
  module Api
    module V3
      module Admin
        class BrandSerializer < V3::BrandSerializer
          attributes :created_at, :updated_at
        end
      end
    end
  end
end
app/controllers/spree/api/v3/admin/brands_controller.rb
module Spree
  module Api
    module V3
      module Admin
        class BrandsController < ResourceController
          protected

          def model_class
            Spree::Brand
          end

          def serializer_class
            Spree::Api::V3::Admin::BrandSerializer
          end

          def permitted_params
            params.permit(:name)
          end
        end
      end
    end
  end
end
Two conventions to notice:
  • The Admin serializer extends the Store serializer — public fields stay in sync automatically, and the Admin side adds back-office data (timestamps here; cost prices, internal notes, and audit fields on richer resources). Customers never see those fields because storefronts use the Store serializer.
  • Admin::ResourceController ships full CRUDindex, show, create, update, and destroy are inherited; permitted_params lists the writable attributes with flat params (no nested brand: {...} wrapping).
With the routes registered (resources :brands under the admin namespace — the generator injects this), back-office clients get:
# Create a brand with a secret API key
curl -X POST -H "X-Spree-API-Key: sk_YOUR_KEY" \
     -H "Content-Type: application/json" \
     -d '{"name": "Adidas"}' \
     http://localhost:3000/api/v3/admin/brands
Secret keys carry scopes (read_brands, write_brands style) and JWT admin users go through CanCanCan abilities — see API authentication for the full model. From TypeScript, the Admin SDK wraps the Admin API with typed clients for all built-in resources.

Step 6: Add Brand to Product Responses

Now let’s extend the Product serializer so that brand data is included when a storefront requests ?expand=brand.

Create a Custom Product Serializer

Subclass the core ProductSerializer and add brand fields. Then swap it in via Dependencies:
app/serializers/my_app/product_serializer.rb
module MyApp
  class ProductSerializer < Spree::Api::V3::ProductSerializer
    typelize brand_id: [:string, nullable: true]

    attribute :brand_id do |product|
      product.brand&.prefixed_id
    end

    one :brand,
        resource: Spree::Api::V3::BrandSerializer,
        if: proc { expand?('brand') }
  end
end
Register it and whitelist the brand association for Ransack filtering in your initializer:
config/initializers/spree.rb
# Swap in custom product serializer with brand support
Spree::Api::Dependencies.product_serializer = 'MyApp::ProductSerializer'

# Allow filtering products by brand (e.g., ?q[brand_name_cont]=nike or ?q[brand_id_eq]=123)
Spree.ransack.add_attribute(Spree::Product, :brand_id)
Spree.ransack.add_association(Spree::Product, :brand)
Without Spree.ransack.add_association, Ransack predicates like brand_name_cont will be silently ignored. Spree whitelists ransackable attributes and associations on each model — custom ones must be registered explicitly.

Understanding the Serializer

  • brand_id — always included as a flat attribute (prefixed ID string), so storefronts know which brand a product belongs to without expanding
  • one :brand — conditionally included when the client requests ?expand=brand, returns the full brand object inline
  • expand?('brand') — checks if the expand query parameter includes 'brand'
We subclass and swap via Spree::Api::Dependencies rather than using a decorator. This is the recommended pattern for customizing core serializers — it’s explicit, easy to test, and other extensions can further subclass your serializer.

How Expand Works

The expand system keeps responses lean by default and lets clients opt-in to nested data:
# Without expand — brand_id only
GET /api/v3/store/products/prod_86Rf07xd4z

# With expand — full brand object included
GET /api/v3/store/products/prod_86Rf07xd4z?expand=brand

# Multiple expands
GET /api/v3/store/products/prod_86Rf07xd4z?expand=brand,variants,categories
Response with ?expand=brand:
{
  "id": "prod_86Rf07xd4z",
  "name": "Air Max 90",
  "brand_id": "brand_k5nR8xLq",
  "brand": {
    "id": "brand_k5nR8xLq",
    "name": "Nike",
    "slug": "nike",
    "description": "Just Do It",
    "logo_url": "https://cdn.example.com/brands/nike-logo.png"
  }
}

Extending Core Serializers (General Pattern)

The pattern we used for Product works for any core serializer. Subclass the core serializer, add your fields, and swap it in via Spree::Api::Dependencies:
app/serializers/my_app/product_serializer.rb
module MyApp
  class ProductSerializer < Spree::Api::V3::ProductSerializer
    attribute :my_field do |product|
      product.my_field
    end
  end
end
config/initializers/spree.rb
Spree::Api::Dependencies.product_serializer = 'MyApp::ProductSerializer'
This works for any core serializer registered in Dependencies (see Spree::Api::ApiDependencies for the full list). Your subclass inherits all existing attributes and associations, and other extensions can further subclass yours.

Complete Files

Brand Model

app/models/spree/brand.rb
module Spree
  class Brand < Spree::Base
    include Spree::PrefixedId
    extend FriendlyId

    has_prefix_id :brand
    friendly_id :slug_candidates, use: [:slugged, :scoped], scope: spree_base_uniqueness_scope

    has_many :products, class_name: 'Spree::Product', dependent: :nullify

    has_one_attached :logo
    has_rich_text :description

    validates :name, presence: true
  end
end

Brand Serializer

app/serializers/spree/api/v3/brand_serializer.rb
module Spree
  module Api
    module V3
      class BrandSerializer < BaseSerializer
        typelize name: :string,
                 slug: [:string, nullable: true],
                 description: [:string, nullable: true],
                 logo_url: [:string, nullable: true]

        attributes :name, :slug

        attribute :description do |brand|
          brand.description&.to_plain_text
        end

        attribute :logo_url do |brand|
          image_url_for(brand.logo) if brand.logo.attached?
        end
      end
    end
  end
end

Brands Controller

app/controllers/spree/api/v3/store/brands_controller.rb
module Spree
  module Api
    module V3
      module Store
        class BrandsController < ResourceController
          protected

          def model_class
            Spree::Brand
          end

          def find_resource
            id = params[:id]
            if id.to_s.start_with?('brand_')
              scope.find_by_prefix_id!(id)
            else
              scope.friendly.find(id)
            end
          end

          def serializer_class
            Spree::Api::V3::BrandSerializer
          end

          def scope
            Spree::Brand.all
          end
        end
      end
    end
  end
end

Custom Product Serializer

app/serializers/my_app/product_serializer.rb
module MyApp
  class ProductSerializer < Spree::Api::V3::ProductSerializer
    typelize brand_id: [:string, nullable: true]

    attribute :brand_id do |product|
      product.brand&.prefixed_id
    end

    one :brand,
        resource: Spree::Api::V3::BrandSerializer,
        if: proc { expand?('brand') }
  end
end

Routes

config/routes.rb
Spree::Core::Engine.add_routes do
  namespace :api, defaults: { format: 'json' } do
    namespace :v3 do
      namespace :store do
        resources :brands, only: [:index, :show]
      end
    end
  end
end

Initializer

config/initializers/spree.rb
# Permit brand_id in product params (from Extending Core Models tutorial)
Spree::PermittedAttributes.product_attributes << :brand_id

# Swap in custom product serializer with brand support
Spree::Api::Dependencies.product_serializer = 'MyApp::ProductSerializer'

# Allow filtering products by brand via Ransack
Spree.ransack.add_attribute(Spree::Product, :brand_id)
Spree.ransack.add_association(Spree::Product, :brand)