Overview
A product represents something you sell. Each product has one or more variants — the actual purchasable items with their own SKU, price, and inventory. For example, a “T-Shirt” product might have variants for each size and color combination.
Products are organized into categories — a flexible hierarchy for grouping products. Categories can be filtered, sorted, and searched via the Store API.
Product names, descriptions, slugs, and SEO fields are translatable.
Product Attributes
| Attribute | Description | Translatable |
|---|
name | Product name | Yes |
description | Full product description | Yes |
slug | URL-friendly identifier (e.g., spree-tote) | Yes |
status | draft, active, or archived | No |
available_on | Date the product becomes available for sale | No |
discontinue_on | Date the product is no longer available | No |
meta_title | Custom SEO title | Yes |
meta_description | SEO description | Yes |
meta_keywords | SEO keywords | Yes |
purchasable | Whether the product can be added to cart | No |
in_stock | Whether any variant has stock available | No |
price | Default variant’s price in the current currency | No |
thumbnail_url | URL to the product’s first image — always returned, no expand needed | No |
tags | Array of tag strings for filtering | No |
Listing Products
// List products with pagination
const { data: products, meta } = await client.products.list({
limit: 12,
page: 1,
})
// Filter by price range and availability
const filtered = await client.products.list({
price_gte: 10,
price_lte: 50,
in_stock: true,
})
// Search by keyword
const results = await client.products.list({
search: 'tote bag',
})
// Sort products
const sorted = await client.products.list({
sort: 'price_high_to_low', // or: price_low_to_high, newest, name_a_z, name_z_a
})
See Querying for the full list of filtering, sorting, and pagination options.
Getting a Product
// Get by slug
const product = await client.products.get('spree-tote')
// Get with included relations
const detailed = await client.products.get('spree-tote', {
expand: ['variants', 'media', 'option_types', 'categories'],
})
// detailed.variants => [{ id: "var_xxx", sku: "TOTE-S-R", price: { amount: "15.99", currency: "USD" }, ... }]
// detailed.media => [{ id: "img_xxx", url: "https://cdn...", position: 1 }]
// detailed.option_types => [{ name: "size", presentation: "Size", option_values: [...] }]
Product Filters
Get available filter options for building a faceted search UI. Returns price ranges, option values, and categories with counts:
const filters = await client.products.filters()
// {
// option_types: [{ name: "size", option_values: [{ name: "Small", count: 12 }, ...] }],
// price_range: { min: 9.99, max: 199.99 },
// categories: [{ id: "ctg_xxx", name: "Clothing", count: 45 }],
// }
// Scoped to a specific category
const categoryFilters = await client.products.filters({
category_id: 'ctg_xxx',
})
Variants
Variants are the purchasable units of a product. Each variant has its own SKU, price, inventory, and images, and is defined by a unique combination of option values.
| Attribute | Description |
|---|
sku | Unique stock keeping unit |
barcode | Barcode (UPC, EAN, etc.) |
price | Price in the current currency |
original_price | Compare-at price for showing discounts |
weight, height, width, depth | Dimensions for shipping calculations |
in_stock | Whether stock is available |
backorderable | Whether the variant can be ordered when out of stock |
option_values | The option values that define this variant (e.g., Size: Small, Color: Red) |
Master Variant
Every product has a master variant that holds default pricing and inventory. If a product has no option types (e.g., a book with no size/color), the master variant is the only purchasable variant.
Regular Variants
When a product has option types, each unique combination of option values creates a variant. For example, a T-shirt with sizes (S, M, L) and colors (Red, Green) has 6 variants:
| SKU | Size | Color |
|---|
TEE-S-R | Small | Red |
TEE-S-G | Small | Green |
TEE-M-R | Medium | Red |
TEE-M-G | Medium | Green |
TEE-L-R | Large | Red |
TEE-L-G | Large | Green |
The product’s default_variant_id points to the first non-master variant (or the master variant if none exist).
Option Types and Option Values
Option types define the axes of variation for a product (e.g., Size, Color, Material). Option values are the specific choices within each type (e.g., Small, Medium, Large).
A product must have at least one option type to have multiple variants. Option types and their values are included in the product response when requested:
const product = await client.products.get('spree-tee', {
expand: ['option_types'],
})
product.option_types?.forEach(optionType => {
console.log(optionType.presentation) // "Size"
optionType.option_values.forEach(value => {
console.log(value.presentation) // "Small", "Medium", "Large"
})
})
Option type name and presentation fields are translatable.
Media can be attached to the product (via the master variant) or to individual variants. When displaying a product, show the images for the selected variant, falling back to the product-level images.
Thumbnails
Every product response includes a thumbnail_url field — the URL to the first image, ready to use without any expands. Similarly, each variant includes a thumbnail_url URL and an media_count counter.
Use these fields for product listing pages to avoid loading all images:
// List products — thumbnail_url is always included
const { data: products } = await client.products.list({ limit: 12 })
products.forEach(product => {
product.thumbnail_url // "https://cdn.../tote-front.jpg" — no expand needed
})
Avoid using ?expand=media on listing pages. This loads all images for every product in the response, which is unnecessary when you only need a thumbnail. Use thumbnail_url instead and only expand full media on the product detail page.
All Images
On the product detail page, expand media and variants to get the full set of images. Images are ordered by position:
const product = await client.products.get('spree-tote', {
expand: ['media', 'variants'],
})
// Product-level images (from master variant)
product.media // [{ url: "https://cdn.../tote-front.jpg", position: 1 }, ...]
// Each variant has its own thumbnail and media_count
product.variants?.forEach(variant => {
variant.thumbnail // "https://cdn.../tote-red.jpg" — always available
variant.media_count // 3 — quick check without loading media
variant.media // full image array (only when ?expand=media)
})
| Field | Available on | Always returned | Description |
|---|
thumbnail_url | Product | Yes | URL to the product’s first media |
thumbnail_url | Variant | Yes | URL to the variant’s first media |
media_count | Variant | Yes | Number of media |
media | Product, Variant | No | Full image array (requires ?expand=media) |
Prices
Each variant can have multiple prices — one per currency, plus additional prices from Price Lists that apply conditionally based on market, geography, customer segment, or quantity.
The API automatically returns the correct price based on the current currency and market context:
| Field | Description |
|---|
price | Current selling price |
original_price | Compare-at price (for showing strikethrough discounts) |
See the Pricing guide for details on Price Lists, Price Rules, and market-specific pricing.
Categories
Categories provide a flexible way to organize products into hierarchical trees. Internally, Spree uses Taxonomies (category trees) and Taxons (nodes within those trees), but the Store API exposes them simply as Categories.
For example:
- Categories → Clothing → T-Shirts, Dresses
- Brands → Nike, Adidas, Puma
- Collections → Summer 2025, Best Sellers
Products can belong to multiple categories.
// List categories
const { data: categories } = await client.categories.list()
// Get a category by permalink
const category = await client.categories.get('clothing/shirts')
// List products in a category
const { data: products } = await client.categories.products.list('clothing/shirts', {
limit: 12,
})
Category name and description fields are translatable.