docs/developer/tutorial/api.mdx
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>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/api/v3/admin/brands — for back-office apps and integrations?expand=brandEverything this page builds by hand can be generated in one command with spree:api_resource:
spree generate api_resource Brand
bin/rails g spree: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.
<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>Every Store API endpoint follows the same pattern:
Spree::Api::V3::Store::ResourceController which provides CRUD, pagination, Ransack filtering, and authorization out of the boxSpree::Api::V3::BaseSerializer (uses Alba) and defines which fields to returnSpree::Core::Engine.add_routesSpree::Api::Dependencies enables dependency injection so serializers can be swapped by extensions or the host appStore API requires two things from models:
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.nike for GET /brands/nikeAdd a slug column:
spree generate migration AddSlugToSpreeBrands slug:string:uniq
spree migrate
bin/rails g migration AddSlugToSpreeBrands slug:string:uniq
bin/rails db:migrate
Then add FriendlyId to the Brand model:
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_k5nR8xLqSpree::Brand.find_by_prefix_id!('brand_k5nR8xLq') finds by prefixed IDSpree::Brand.friendly.find('nike') finds by slugname via slug_candidates (inherited from the Spree base class)Create a serializer that defines the JSON response shape for brands:
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
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 SDKattributes lists database columns to include directlyattribute ... do blocks define computed fields (like stripping HTML from rich text, or generating image URLs)Create a controller that inherits from Store::ResourceController:
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
ResourceController gives you index and show actions automatically. You only need to define:
| Method | Purpose |
|---|---|
model_class | Which ActiveRecord model to query |
serializer_class | Which serializer to render responses with |
scope | Base query scope (add .where(...) to filter) |
The base controller handles:
?page=2&limit=25)?q[name_cont]=nike)?sort=-name for descending)show action (/brands/brand_k5nR8xLq)To also support fetching brands by slug (like products support /products/blue-t-shirt), override find_resource:
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
Add the routes for your new endpoints:
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/sortingGET /api/v3/store/brands/:id — single brand by prefixed ID or slugRestart 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"
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 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:
module Spree
module Api
module V3
module Admin
class BrandSerializer < V3::BrandSerializer
attributes :created_at, :updated_at
end
end
end
end
end
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:
Admin::ResourceController ships full CRUD — index, 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
Now let's extend the Product serializer so that brand data is included when a storefront requests ?expand=brand.
Subclass the core ProductSerializer and add brand fields. Then swap it in via Dependencies:
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:
# 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)
brand_id — always included as a flat attribute (prefixed ID string), so storefronts know which brand a product belongs to without expandingone :brand — conditionally included when the client requests ?expand=brand, returns the full brand object inlineexpand?('brand') — checks if the expand query parameter includes 'brand'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"
}
}
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:
module MyApp
class ProductSerializer < Spree::Api::V3::ProductSerializer
attribute :my_field do |product|
product.my_field
end
end
end
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.
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
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
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
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
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
# 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)