docs/developer/tutorial/store-api.mdx
In this tutorial, we'll create Store API endpoints for our Brand model so that storefronts can list and display brands. 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 — list all brandsGET /api/v3/store/brands/:id — get a single brand by prefixed ID or slug?expand=brandEvery 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 IDsnike for GET /brands/nikeFirst, add a slug column if you haven't already:
bin/rails g migration AddSlugToSpreeBrands slug:string:uniq
bin/rails db:migrate
Then update the Brand model with PrefixedId and FriendlyId:
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
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 Spree::Base)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
}
}
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)