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/brandsandGET /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 withspree:api_resource:
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.
How the Store API Works
Every Store API endpoint follows the same pattern:- Controller inherits from
Spree::Api::V3::Store::ResourceControllerwhich provides CRUD, pagination, Ransack filtering, and authorization out of the box - Serializer inherits from
Spree::Api::V3::BaseSerializer(uses Alba) and defines which fields to return - Routes are added via
Spree::Core::Engine.add_routes - Serializer registration via
Spree::Api::Dependenciesenables 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:- Prefixed IDs — Stripe-style IDs like
brand_k5nR8xLqinstead of raw database IDs. Thespree:modelgenerator already addedhas_prefix_id :brandin the Model step, so this is done. - Slugs — human-readable URL identifiers like
nikeforGET /brands/nike
slug column:
FriendlyId to the Brand model:
app/models/spree/brand.rb
Spree::Brand.first.prefixed_idreturnsbrand_k5nR8xLqSpree::Brand.find_by_prefix_id!('brand_k5nR8xLq')finds by prefixed IDSpree::Brand.friendly.find('nike')finds by slug- Slugs are auto-generated from the
nameviaslug_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
Understanding the Serializer
BaseSerializerautomatically convertsidto a prefixed ID and provides context helpers (current_store,current_currency, etc.)typelizeprovides type hints used by Typelizer to auto-generate TypeScript types for the SDKattributeslists database columns to include directlyattribute ... doblocks define computed fields (like stripping HTML from rich text, or generating image URLs)
Step 3: Create the Controller
Create a controller that inherits fromStore::ResourceController:
app/controllers/spree/api/v3/store/brands_controller.rb
Understanding the Controller
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) |
- Pagination via Pagy (
?page=2&limit=25) - Filtering via Ransack (
?q[name_cont]=nike) - Sorting via JSON:API style (
?sort=-namefor descending) - Authorization via CanCanCan
- Prefixed ID lookup for
showaction (/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
Step 4: Add Routes
Add the routes for your new endpoints:config/routes.rb
GET /api/v3/store/brands— paginated list with filtering/sortingGET /api/v3/store/brands/:id— single brand by prefixed ID or slug
Step 5: Test the Endpoints
Restart your server and test:Response Format
List response: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
app/controllers/spree/api/v3/admin/brands_controller.rb
- 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::ResourceControllerships full CRUD —index,show,create,update, anddestroyare inherited;permitted_paramslists the writable attributes with flat params (no nestedbrand: {...}wrapping).
resources :brands under the admin namespace — the generator injects this), back-office clients get:
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 coreProductSerializer and add brand fields. Then swap it in via Dependencies:
app/serializers/my_app/product_serializer.rb
config/initializers/spree.rb
Understanding the Serializer
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 theexpandquery 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:?expand=brand:
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 viaSpree::Api::Dependencies:
app/serializers/my_app/product_serializer.rb
config/initializers/spree.rb
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
Brand Serializer
app/serializers/spree/api/v3/brand_serializer.rb
Brands Controller
app/controllers/spree/api/v3/store/brands_controller.rb
Custom Product Serializer
app/serializers/my_app/product_serializer.rb
Routes
config/routes.rb
Initializer
config/initializers/spree.rb
Related Documentation
- Extending Core Models - Adding the Brand association to Product
- Events & Webhooks - Lifecycle events for Brand and external-system integration (next step)
- Using Brands with the SDK - Consuming brand endpoints from TypeScript
- Decorators - Full decorator reference
- Dependencies - Swapping services and serializers

