Back to Spree

Expose a custom model through the Store and Admin APIs

docs/developer/tutorial/api.mdx

5.5.018.9 KB
Original Source

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.

<Info> This guide assumes you've completed the [Model](/developer/tutorial/model), [Admin](/developer/tutorial/admin), and [Extending Core Models](/developer/tutorial/extending-models) tutorials. </Info>

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:

<CodeGroup>
bash
spree generate api_resource Brand
bash
bin/rails g spree:api_resource Brand
</CodeGroup>

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:

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

<Tip> Using an AI agent? The [Spree agent skills](/developer/agentic/agent-skills) include a dedicated resource-generator skill — your agent knows the field syntax, the flags, and the generated-file contract. </Tip>

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:

<CodeGroup>
bash
spree generate migration AddSlugToSpreeBrands slug:string:uniq
spree migrate
bash
bin/rails g migration AddSlugToSpreeBrands slug:string:uniq
bin/rails db:migrate
</CodeGroup>

Then add FriendlyId to the Brand model:

ruby
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:

ruby
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:

ruby
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)
<Info> 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. </Info>

Adding Slug Lookup

To also support fetching brands by slug (like products support /products/blue-t-shirt), override find_resource:

ruby
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:

ruby
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:

bash
# 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:

json
{
  "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:

ruby
module Spree
  module Api
    module V3
      module Admin
        class BrandSerializer < V3::BrandSerializer
          attributes :created_at, :updated_at
        end
      end
    end
  end
end
ruby
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:

bash
# 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
<Info> Secret keys carry scopes (`read_brands`, `write_brands` style) and JWT admin users go through CanCanCan abilities — see [API authentication](/developer/customization/api) for the full model. From TypeScript, the [Admin SDK](/developer/sdk/admin/quickstart) wraps the Admin API with typed clients for all built-in resources. </Info>

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:

ruby
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:

ruby
# 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)
<Warning> 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. </Warning>

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'
<Info> 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. </Info>

How Expand Works

The expand system keeps responses lean by default and lets clients opt-in to nested data:

bash
# 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:

json
{
  "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:

ruby
module MyApp
  class ProductSerializer < Spree::Api::V3::ProductSerializer
    attribute :my_field do |product|
      product.my_field
    end
  end
end
ruby
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

ruby
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

ruby
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

ruby
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

ruby
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

ruby
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

ruby
# 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)