spree-storefront
Use when the user is working on the optional Next.js storefront (the customer-facing online store) — adding a page, customizing checkout, fetching products, integrating with Spree's Store API. Common phrasings include "customize storefront", "Next.js storefront", "frontend changes", "PDP", "product page", "cart", "checkout flow", "@spree/sdk", "publishable key". Provides the storefront architecture, the @spree/sdk integration model, and the storefront-vs-backend decision tree.
Skill body
Spree Storefront (Next.js)
The Spree Next.js storefront is a separate application that talks to the Spree backend over the v3 Store API. It’s hosted at github.com/spree/storefront and cloned separately into your project tree.
The storefront is optional. Headless deployments may use a custom frontend — React Native, Astro, Remix, or hand-rolled. Spree’s job is to expose a clean API; what consumes it is your choice. This skill assumes the official Next.js storefront, but the API contract is identical for any frontend.
How it connects to Spree
Browser ──HTTPS──> Next.js storefront ──API──> Spree backend (Rails)
│
└── @spree/sdk for typed API calls
The storefront authenticates against the Spree backend via a publishable API key (pk_… prefix). Customer-bound operations (their cart, their account) use additional auth — JWT for logged-in customers, cart tokens for guest carts.
# .env.local — server-side only (the storefront makes all API calls via Server Actions)
SPREE_API_URL=http://localhost:3000
SPREE_PUBLISHABLE_KEY=pk_…
@spree/sdk — the canonical client
Don’t hand-write fetch calls. Use @spree/sdk for typed access to the Store API:
import { createClient } from '@spree/sdk'
const spree = createClient({
baseUrl: process.env.SPREE_API_URL!,
publishableKey: process.env.SPREE_PUBLISHABLE_KEY!,
})
// List products
const { data, meta } = await spree.products.list({
expand: ['media', 'default_variant'],
})
// Get a single product by slug or prefixed ID
const product = await spree.products.get('cool-shirt')
// Create a cart
const cart = await spree.carts.create()
// Add item to cart — cart ID positional, token via options.spreeToken
await spree.carts.items.create(cart.id, {
variant_id: 'variant_k5nR8xLq',
quantity: 1,
}, { spreeToken: cart.token })
The SDK includes:
- Full TypeScript types generated from the Spree serializers (
Product,Order,Cart, etc.) - Runtime Zod schemas in
@spree/sdk/zodif you want validation - Automatic retry with exponential backoff
- Ransack query param transformation
- Webhook signature verification in
@spree/sdk/webhooks
Authentication modes
| Who | How | Use for |
|---|---|---|
| Anonymous browser | Publishable key | Browsing products, viewing categories |
| Guest cart | Publishable key + cart token | Cart operations for not-yet-signed-up customers |
| Logged-in customer | Publishable key + JWT (customer login) | Order history, saved addresses, account pages |
The customer login flow:
const { token, refresh_token, user } = await spree.auth.login({
email: 'jane@example.com',
password: 'secret',
})
// Pass the JWT per request via options.token
const orders = await spree.customer.orders.list({}, { token })
const me = await spree.customer.get({ token })
// Refresh later
const { token: newToken } = await spree.auth.refresh({ refresh_token })
Channels — which sales surface
If the merchant has multiple channels (website, mobile app, in-store POS), the storefront should identify which one it represents. Set the channel via the SDK config:
const spree = createClient({
baseUrl: process.env.SPREE_API_URL!,
publishableKey: process.env.SPREE_PUBLISHABLE_KEY!,
channel: 'online', // channel code; or prefixed ID like 'ch_…'
})
The Spree backend uses Spree::Current.channel to scope queries — only products published on that channel surface in API responses. If channel is omitted, the store’s default channel is used.
Common storefront patterns
Server-rendered PDP
// app/products/[slug]/page.tsx
import { spree } from '@/lib/spree'
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await spree.products.get(params.slug, {
expand: ['default_variant', 'variants', 'media', 'categories'],
})
return (
<main>
<h1>{product.name}</h1>
<img src={product.media?.[0]?.large_url ?? undefined} alt={product.media?.[0]?.alt ?? ''} />
<AddToCartButton variantId={product.default_variant_id} />
</main>
)
}
The v3 Store API uses flat responses — product.name, not product.data.attributes.name. Related records appear as either ID fields (e.g. default_variant_id) or, when expanded, as nested objects (e.g. product.default_variant, product.media[]).
Client-side cart
Carts are server-state, so use SWR or React Query. The cart ID + token persist in a cookie:
'use client'
import useSWR from 'swr'
export function MiniCart({ cartId, token }: { cartId: string; token: string }) {
const { data: cart } = useSWR(
['cart', cartId],
() => spree.carts.get(cartId, { spreeToken: token })
)
if (!cart) return null
return <span>{cart.items.length} items</span>
}
Checkout
The Store API exposes payment sessions for the checkout flow — a single, provider-agnostic endpoint that works with any session-based gateway (Stripe, Adyen, PayPal); the provider is selected via payment_method_id. The pattern:
- Customer hits checkout —
POST /api/v3/store/carts/:cart_id/payment_sessionswith a payment method choice (cart token inX-Spree-Tokenheader). - Backend returns a session with provider-specific data (Stripe Checkout URL, Adyen drop-in token, etc.).
- Storefront redirects to the provider OR renders the provider’s embedded form.
- Customer completes — provider posts back to the Spree backend, which fires
payment_session.completedevents. - Storefront calls
spree.carts.paymentSessions.complete(cartId, sessionId, { session_result: 'success' }, options)once the customer confirms, thenspree.carts.complete(cartId, options)to get the Order — or relies on the provider webhook, in which case the backend completes the cart → order transition automatically.
The spree_stripe / spree_adyen / spree_paypal_checkout gems ship reference checkout flows. Don’t roll your own unless you’re integrating a new provider.
Webhook handling
For Next.js storefronts, @spree/sdk/webhooks provides HMAC signature verification with typed event payloads:
// app/api/webhooks/spree/route.ts
import { verifyWebhookSignature, type WebhookEvent } from '@spree/sdk/webhooks'
export async function POST(req: Request) {
const body = await req.text()
const signature = req.headers.get('x-spree-webhook-signature') ?? ''
const timestamp = req.headers.get('x-spree-webhook-timestamp') ?? ''
if (!verifyWebhookSignature(body, signature, timestamp, process.env.SPREE_WEBHOOK_SECRET!)) {
return new Response('Invalid signature', { status: 401 })
}
const event: WebhookEvent = JSON.parse(body)
switch (event.name) {
case 'order.completed':
await sendCustomThankYouEmail(event.data)
break
case 'order.shipped':
await pushShippingNotification(event.data)
break
}
return Response.json({ received: true })
}
The Spree backend ships outbound webhooks as Spree::WebhookEndpoint records. Configure URL + events under Settings → Webhooks in the admin.
Storefront vs backend — where does the change belong
| Want to… | Belongs in |
|---|---|
| Change how a product is displayed (layout, colors, copy) | Storefront |
| Add a new field to product responses | Backend (model + serializer) |
| Add a custom page like /about, /shipping | Storefront |
| Change pricing logic | Backend (service swap or extension) |
| Add a country to checkout | Backend (Markets / Country config) |
| Customize the checkout UI flow | Storefront |
| Add an A/B test to the PDP | Storefront |
| Sync orders to a CRM | Backend (subscriber) |
| Custom analytics events | Storefront (client-side tracking) OR backend (subscriber) — depends on what triggers them |
| Customize the cart total calculation | Backend (service swap on Spree.cart_recalculate_service) |
| Send a custom transactional email | Backend (subscriber + ActionMailer) |
The rule: anything customer-visible is the storefront. Anything that touches data, money, or business logic is the backend. When in doubt, backend — keeping logic centralized makes it consistent across all frontends if you ever ship a second one.
Common gotchas
- Don’t ship secret keys to the browser. Only publishable keys (
pk_…) belong inNEXT_PUBLIC_*env vars. Secret API keys (sk_…) are server-side only. - Cart tokens are not credentials — they identify a cart, not a user. But they grant cart access, so treat them like a session token: HTTPS only, set as an httpOnly cookie when possible.
- Cache aggressively but invalidate on cart/auth changes. Product catalog can sit in CDN; cart calls must always hit fresh.
- Pricing displayed must match what the API will charge. Don’t compute totals client-side. Always pull the cart’s
totalfrom the API after add/remove operations — the backend applies promotions, taxes, shipping rules. - i18n is the storefront’s job. The Store API returns translated strings based on the
x-spree-localeheader (or alocalequery param) — notAccept-Language. Set it via the SDK:createClient({ ..., locale })for a default, or pass{ locale }in per-request options; the SDK sends it asx-spree-locale.
Where to read further
- SDK docs:
node_modules/@spree/docs/dist/developer/sdk/quickstart.mdx(also at https://spreecommerce.org/docs/developer/sdk/quickstart) - Storefront docs:
node_modules/@spree/docs/dist/developer/storefront/nextjs/architecture.mdx,customization.mdx,deployment.mdx - Storefront tutorial:
node_modules/@spree/docs/dist/developer/tutorial/api.md,sdk.md - Storefront source: https://github.com/spree/storefront — reference implementations for product listing, cart, checkout, account pages