The Spree Next.js storefront is a separate app that talks to the Spree backend over the v3 Store API (/api/v3/store/*) using @spree/sdk 2.x. create-spree-app puts it in apps/storefront/ (next to server/ and apps/dashboard/); or fork https://github.com/spree/storefront. Next.js 16, React 19, Tailwind 4.
The storefront is optional — any frontend (Astro, Remix, React Native, …) can use the same Store API. This skill assumes the official storefront; the API contract is identical for any client.
Architecture — server-first
Browser → Server Action (src/lib/data/*) → @spree/sdk → Spree Store API
httpOnly cookies (JWT, refresh token, cart token, country/locale) via src/lib/spree helpers
- All API calls run server-side (Server Actions / Server Components in
src/lib/data/). The browser never calls Spree directly; even the publishable key stays server-only (SPREE_PUBLISHABLE_KEY, noNEXT_PUBLIC_). src/lib/spree/holds the integration:getClient(), cookie helpers,withAuthRefresh(),getLocaleOptions(),getCartOptions(), middleware, surfaces (DTC vs wholesale), webhooks.- Client components (
CartContext,CheckoutContext, …) hold UI state and call Server Actions — don't add SWR/React Query fetches against the Store API from the browser.
// src/lib/data/customer.ts (pattern)
import { getClient, withAuthRefresh, setAccessToken, setRefreshToken } from '@/lib/spree'
export async function login(email: string, password: string) {
const { token, refresh_token, user } = await getClient().auth.login({ email, password })
await setAccessToken(token)
await setRefreshToken(refresh_token)
return { success: true, user }
}
export async function getCustomer() {
return withAuthRefresh((options) => getClient().customer.get(options))
}
Required env (.env.local): SPREE_API_URL, SPREE_PUBLISHABLE_KEY. Optional: NEXT_PUBLIC_DEFAULT_COUNTRY / NEXT_PUBLIC_DEFAULT_LOCALE, NEXT_PUBLIC_SITE_URL, SPREE_WHOLESALE_CHANNEL, SPREE_WHOLESALE_PUBLISHABLE_KEY, SPREE_WEBHOOK_SECRET, RESEND_API_KEY, EMAIL_FROM. NEXT_PUBLIC_* is only for values the browser genuinely needs (default region, Stripe/PayPal publishable keys).
@spree/sdk essentials
import { createClient } from '@spree/sdk'
const spree = createClient({
baseUrl: process.env.SPREE_API_URL!,
publishableKey: process.env.SPREE_PUBLISHABLE_KEY!,
locale: 'en', // sent as x-spree-locale (also per request: { locale })
channel: 'online', // sent as X-Spree-Channel; omit for the store default
})
const { data, meta } = await spree.products.list({ expand: ['media', 'default_variant'] })
const product = await spree.products.get('cool-shirt', { expand: ['variants', 'media'] }) // slug or prod_ id
const cart = await spree.carts.create()
await spree.carts.items.create(cart.id, { variant_id: 'variant_k5nR8xLq', quantity: 1 }, { spreeToken: cart.token })
const orders = await spree.customer.orders.list({}, { token }) // logged-in customer (JWT)
- Flat responses (
product.name); relations are*_idfields or, withexpand, nested objects (product.default_variant,product.primary_media,product.media[]). Prices come asPriceobjects (product.price.display_amount) — never compute money client-side. - Auth modes: publishable key only (browsing) · +
spreeToken(guest cart,x-spree-token) · +tokenJWT (customer). Types in@spree/sdk(also@spree/sdk/zod), webhook helpers in@spree/sdk/webhooks. - IDs are prefixed strings (
prod_,variant_,cart_,or_,cust_,ful_). Carts usecart.id—cart.numberis a deprecated mirror ofidkept for 5.x clients (removed in 6.1).
Cart → checkout → order
Spree 6 splits the mutable Cart (cart_…) from the immutable Order (or_…) created when the cart completes. There is no checkout state machine to advance: you update the cart (email, addresses, delivery rate, payment) in any order, and the cart reports what's still missing.
const cart = await spree.carts.get(cartId, { spreeToken })
cart.requirements
// [{ step: 'address', field: 'ship_address', code: 'ship_address_required', message: '…' },
// { step: 'delivery', field: 'delivery_method', code: 'delivery_method_required', message: '…' }, …]
requirements[]entries are{ step, field, code, message }. Codes includeline_items_required,email_required,ship_address_required,delivery_method_required,payment_required,po_number_required,guest_checkout_not_allowed,quantity_rule_violated,order_minimum_not_met,discontinued,out_of_stock. Steps (cart,address,delivery,payment) are advisory UI grouping — map them to checkout sections and showmessage. Completion is the only hard gate. Custom requirements come from the backend (Spree::Checkout::Registry.add_requirement, seespree-checkout).- Delivery:
spree.carts.fulfillments.update(cartId, fulfillmentId, { selected_delivery_rate_id }, opts)returns the recalculated cart. - Payment (session-based gateways — Stripe, Adyen, PayPal):
spree.carts.paymentSessions.create(cartId, { payment_method_id }, opts)→ provider data (client secret, drop-in token, redirect URL).- Render the provider UI / redirect.
spree.carts.paymentSessions.complete(cartId, sessionId, { session_result }, opts).spree.carts.complete(cartId, opts)→ returns the Order (typedOrder | OrderGroup— a multi-seller marketplace cart returns anogrp_…group; narrow withisOrderGroupfrom@spree/sdk). It's idempotent: if a gateway webhook already completed the cart, it returns the existing result. Non-session methods (check, COD, bank transfer):spree.carts.payments.create(...).
- After completion, the cart is gone from
carts.get— read the result withspree.orders.get(id, params, { spreeToken | token }). It accepts theor_…id or the originalcart_…id, so the confirmation page (order-placed/[id]) can keep using the cart id as its handle. Orderstatusisdraft/placed/canceled, with separatepayment_statusandfulfillment_status. - Reference flows:
src/lib/data/checkout.ts,payment.ts(completeCheckoutOrderalso tolerates 403/422 races),components/checkout/*, and the gateway gems (e.g.spree_stripe).
Multi-region URLs
Every route lives under src/app/[country]/[locale]/… (/us/en/products, /de/de/products), mapped to a Spree Market (country + currency + locale). src/proxy.ts (Next 16's middleware file) wires createSpreeMiddleware({ defaultCountry, defaultLocale, staticRoutes? }) from @/lib/spree/middleware, which:
- redirects bare paths to
/{country}/{locale}/…; - detects country from the
spree_countrycookie →x-vercel-ip-country/cf-ipcountry→ default, and locale fromspree_locale→Accept-Language→ default; - syncs the
spree_country/spree_localecookies with the URL.
Data functions don't take region args — they call getLocaleOptions(), which reads those cookies and passes locale/country to the SDK. New pages go under src/app/[country]/[locale]/(storefront)/your-page/page.tsx to inherit the layout. CountrySwitcher changes region by navigating to a new prefix.
Wholesale / channel surfaces
/wholesale is an opt-in B2B portal on a separate channel, enabled only when SPREE_WHOLESALE_CHANNEL is set to a gated channel's code (unset → all wholesale UI hidden, routes 404). A surface = a channel-bound SDK client (channel: 'wholesale' → X-Spree-Channel) with its own cart cookie and cache tags; the customer JWT is shared. The channel's storefront_access (login_required | prices_hidden | public) is enforced by the API (401, or money fields null); buyer approval = membership in the Wholesale customer group; trade prices come from backend price lists. Treat it as a reference you can adapt (members-only DTC, B2B-only store) — see spree-b2b.
Webhooks and transactional emails
The storefront can own customer emails (react-email templates in src/lib/emails/, sent via Resend; written to .next/emails/ in dev; preview at /dev/emails). The route src/app/api/webhooks/spree/route.ts uses createWebhookHandler from @/lib/spree/webhooks, which verifies X-Spree-Webhook-Signature (HMAC-SHA256 over "{timestamp}.{body}", timing-safe, 5-minute replay window) and dispatches by event name:
import { createWebhookHandler } from '@/lib/spree/webhooks'
const handler = createWebhookHandler({
secret: process.env.SPREE_WEBHOOK_SECRET!,
handlers: {
'order.placed': handleOrderPlaced,
'order.canceled': handleOrderCanceled,
'order.fulfilled': handleOrderFulfilled, // or 'fulfillment.fulfilled' for per-parcel emails
'customer.password_reset_requested': handlePasswordReset, // must be subscribed by name — `*` never receives it
},
// waitUntil, toleranceSeconds optional
})
export const POST = handler
- Event names are Spree 6's:
order.placed(notorder.completed),order.fulfilled/fulfillment.fulfilled(notorder.shipped). The shipped storefront code still registersorder.completedandorder.shipped— deprecated aliases Spree 6.0 still sends alongside the new names and stops sending in 6.1. Switch handlers and endpoint subscriptions to the new names. - Marketplace (split) checkouts: child orders publish
order.placedwithnotify_customer: false; the purchase's confirmation belongs toorder_group.completed— add a handler for it if you send order emails (seespree-marketplace). - When the storefront owns customer emails, turn off Spree's own with the store preference
send_consumer_transactional_emails(false) so customers don't get two. That switch (andnotify_customer: falseon an order) only silences customer emails — the store's new-order notification tonew_order_notifications_emailstill goes out whenever that address is set. - Create the endpoint in the dashboard under Settings → Developer → Webhooks, subscribe to the events, and copy its secret into
SPREE_WEBHOOK_SECRET. For local testing expose the storefront (cloudflared tunnel --url http://localhost:3001). - Failed deliveries are retried with backoff (5 attempts over about six minutes, same event
id), and an endpoint is disabled after 15 consecutive failed deliveries — return 2xx quickly, dedupe onevent.id, and do slow work inwaitUntil/a queue. Unhandled events return 200{ handled: false }. - Outside the official storefront, use
verifyWebhookSignature(body, signature, timestamp, secret)andtype WebhookEvent<T>from@spree/sdk/webhooks. Full event list:spree-events-webhooks.
Testing
- Unit/integration: Vitest —
pnpm test/pnpm test:watch(tests live next to code, e.g.src/lib/data/__tests__). - E2E: Playwright against a real backend in Docker: export a matching Stripe test key pair (
STRIPE_PUBLISHABLE_KEY,STRIPE_SECRET_KEY),pnpm e2e:up(boots Postgres/Redis +ghcr.io/spree/spree:latest, seeds via@spree/cli, writes.env.e2e),pnpm test:e2e(:uifor interactive),pnpm e2e:down. - Test against your backend:
SPREE_IMAGE=<image> pnpm e2e:up. In acreate-spree-appproject build it from the root withdocker build . -f server/Dockerfile -t project-spree:e2efirst; generated CI does this for you.
Storefront vs backend — where does the change belong
| Want to… | Belongs in |
|---|---|
| Layout, copy, styling, a new content page | Storefront (src/app/[country]/[locale]/(storefront)/…, @theme tokens in src/app/globals.css) |
| Add a field to product/cart responses | Backend — model + serializer swap (spree-resource, spree-api-v3) |
| Change pricing | Backend — price lists / rules (spree-pricing) |
| Change how cart totals are calculated | Backend — Spree::Carts::RecalculateTotals (DI key cart_recalculate_totals_workflow) or a workflow hook (spree-order-totals, spree-workflows) |
| Add a checkout requirement (e.g. "accept terms") | Backend Spree::Checkout::Registry.add_requirement; storefront renders the new requirements[] code |
| Add a country / currency / language | Backend Markets; storefront just follows the URL + cookies |
| Customize the checkout UI | Storefront (components/checkout/*) |
| A/B test the PDP, client analytics | Storefront (src/lib/analytics) |
| Sync orders to a CRM/ERP | Backend subscriber on order.placed (spree-events-webhooks) |
| Customer email look & feel | Storefront emails (webhooks) or backend mailers — pick one owner |
Rule: anything customer-visible is storefront; anything that touches data, money, or business rules is backend, so every frontend gets the same behavior.
Gotchas
- Secret keys (
sk_…) never go in a storefront — not even server-side; the Store API uses publishable keys. Admin tasks belong in the dashboard or a backend job. - Cart tokens grant cart access — keep them in httpOnly, secure cookies (the helpers do).
- Cache catalog, never carts/checkout. The storefront uses Next cache tags per surface (
updateTag(cartTag(surface))) — invalidate after cart mutations. - Don't assume a checkout step order — drive the UI from
requirements[], and handlecarts.completereturning an error (codecart_cannot_complete) by re-reading the cart'srequirements. - Don't call
carts.getafter completion — useorders.getwith the cart or order id. - Localization is header-driven (
x-spree-locale,X-Spree-Channel), notAccept-Language; the storefront gets it from the URL segments via cookies. - Stay on the upstream track: add the storefront repo as an
upstreamremote and merge periodically; keep customizations in components/data files you own.
Where to read further
- Docs (installed):
node_modules/@spree/docs/dist/developer/storefront/nextjs/{architecture,customization,multi-region,wholesale,emails,environment-variables,testing,deployment,wallet-payments}.md; SDK:developer/sdk/quickstart.md. Online: https://spreecommerce.org/docs/developer/storefront/nextjs/architecture - Tutorial:
developer/tutorial/storefront.md - Source: https://github.com/spree/storefront (
src/lib/spree/,src/lib/data/,src/app/api/webhooks/spree/route.ts) - Related skills:
spree-typescript-sdk,spree-api-v3,spree-checkout,spree-events-webhooks,spree-b2b,spree-i18n,spree-deployment.