A production-ready, headless ecommerce storefront for Spree Commerce, built with Next.js 16, React 19, and the Spree REST API. Open source (MIT) and free to fork and customize.
Live Demo | Quickstart Docs | TypeScript SDK
TypeScript SDK. @spree/sdk is an official typed client for every Store API endpoint (OpenAPI 3.0 documented). Autocomplete and type safety in your editor, no codegen step to maintain.
Multi-region out of the box. Country, currency, and language switching via URL segments (/us/en/, /de/de/, /uk/en/), powered by Spree Markets. Distinct selling regions bundling geography, currency, and locale in a single store.
One-page checkout. Guest and authenticated users, multi-shipment, coupon codes, gift cards, and store credit. Stripe, PayPal, and Adyen via Payment Sessions. Card data never touches your server. Swap providers easily.
Transactional emails. Order confirmation, shipping notification, password reset. Built with react-email, sent via Resend, triggered by Spree webhooks.
MIT licensed. Open source and free to use.
The live demo at demo.spreecommerce.org scores 98/100 on desktop and 88/100 on mobile for Performance on Google's Lighthouse audit, with five language versions served from the same deployment.
| Lighthouse metric | Mobile | Desktop |
|---|---|---|
| Performance | 88 | 98 |
| Accessibility | 100 | 100 |
| Best Practices | 100 | 100 |
| SEO | 100 | 100 |
No external performance plugins. No edge-side rendering hacks. The architecture is Next.js 16 App Router with React 19 Server Components, server-side data fetching via @spree/sdk, and Tailwind CSS 4.
Run the audit yourself on PageSpeed Insights
| Feature | Details |
|---|---|
| Product Catalog | Browse, search, filter products by categories, and use faceted navigation. Search and facet filtering powered by Meilisearch |
| Product Details | View product information with variant selection and media |
| Shopping Cart | Add, update, and remove items with server-side state |
| One-page Checkout | Guest visitors and signed-in users supported, multi-shipments supported natively, Coupon Codes, Gift Cards, Store Credit |
| Stripe payments | native Stripe payment support with Stripe SDKs, PCI-Compliant, 3DS-Secure, use Credit Cards, Apple Pay, Google Pay, Klarna, Affirm, SEPA payments, and all other payment methods provided by Spree Stripe integration |
| Customer Account | Full account management: Profile management, Order history with detailed order view, Address book (create, edit, delete), Gift Cards and Store Credit, Saved payment methods |
| Multi-Region Support | Country, currency, and language switching via URL segments, powered by Spree Markets |
| Responsive Design | Mobile-first Tailwind CSS styling |
| Google Tag Manager and Google Analytics 4 Ecommerce events | tracking supported natively |
| Store Policies | Policy pages fetched from Spree API, with consent checkboxes on registration and guest checkout |
| SEO-ready | meta tags, JSON-LD, OpenGraph — all built in |
| Error Tracking | Sentry integration for both server-side and client-side error monitoring with source maps |
| Technology | Role |
|---|---|
| Next.js 16 | App Router, Server Actions, Turbopack |
| React 19 | Latest React with improved Server Components |
| Tailwind CSS 4 | Utility-first styling |
| TypeScript | Full type safety |
| Sentry | Error tracking and performance monitoring with source maps |
| @spree/sdk | Official Spree Commerce SDK |
This starter follows a server-first pattern:
- Server-First Architecture - All API calls happen server-side using Next.js Server Actions
- httpOnly Cookies - Auth tokens and cart tokens are stored securely
- No Client-Side API Calls - The Spree API key is never exposed to the browser
- Cache Revalidation - Uses Next.js cache tags for efficient updates
Browser → Server Action → @spree/sdk → Spree API
(with httpOnly cookies via src/lib/spree helpers)
- Node.js 20+ (required for Next.js 16)
- A running Spree Commerce 5.4+
- Install dependencies:
npm install- Copy the environment file and configure:
cp .env.local.example .env.local- Update
.env.localwith your Spree API credentials:
SPREE_API_URL=http://localhost:3000
SPREE_PUBLISHABLE_KEY=your_publishable_api_key_hereNote: These are server-side only variables (no
NEXT_PUBLIC_prefix needed).
| Variable | Description | Default |
|---|---|---|
NEXT_PUBLIC_SITE_URL |
Public site URL for sitemap and robots.txt generation (e.g. https://mystore.com) |
(required for sitemap) |
NEXT_PUBLIC_DEFAULT_COUNTRY |
Default country ISO code, used for initial redirects and as build-time fallback for sitemap generation | us |
NEXT_PUBLIC_DEFAULT_LOCALE |
Default locale code, used for initial redirects and as build-time fallback for sitemap generation | en |
GTM_ID |
Google Tag Manager container ID (e.g. GTM-XXXXXXX) |
(disabled) |
SENTRY_DSN |
Sentry DSN for error tracking (e.g. https://key@o0.ingest.sentry.io/0) |
(disabled) |
SENTRY_ORG |
Sentry organization slug (for source map uploads) | (none) |
SENTRY_PROJECT |
Sentry project slug (for source map uploads) | (none) |
SENTRY_AUTH_TOKEN |
Sentry auth token (for source map uploads in CI) | (none) |
SPREE_WEBHOOK_SECRET |
Webhook endpoint secret key (for transactional emails) | (disabled) |
RESEND_API_KEY |
Resend API key for sending emails in production | (dev: writes to disk) |
EMAIL_FROM |
"From" address for transactional emails (e.g. Store <orders@mystore.com>) |
orders@example.com |
SENTRY_SEND_DEFAULT_PII |
Send PII (IP addresses, cookies, user data) to Sentry server-side | false |
NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PII |
Send PII to Sentry client-side | false |
Privacy note: PII collection is disabled by default. Only set
SENTRY_SEND_DEFAULT_PII/NEXT_PUBLIC_SENTRY_SEND_DEFAULT_PIItotrueif you have appropriate user consent or a privacy policy covering this data.
npm run devOpen http://localhost:3001 in your browser.
Apple Pay and Google Pay require HTTPS and a publicly-reachable URL — Stripe verifies the payment method domain from the internet, so localhost and locally-trusted certificates (e.g. mkcert + lvh.me) won't pass domain verification. The simplest way to expose your local storefront with a valid public HTTPS URL is Cloudflare Tunnel:
- Install
cloudflared:
brew install cloudflared- Start the dev server normally (HTTP on port 3001):
npm run dev- In a second terminal, expose it through a quick tunnel:
cloudflared tunnel --url http://localhost:3001The output will contain a URL like https://<random-words>.trycloudflare.com.
-
Register that URL in your Stripe Payment method domains.
-
Open the tunnel URL in your browser and test the Express Checkout buttons in the cart.
allowedDevOrigins:next.config.tsalready allows*.trycloudflare.com, so quick tunnels work out of the box. Every time you restartcloudflared tunnel --url ...you get a new random subdomain — if you need a stable URL (to avoid re-registering in Stripe on each run), set up a named tunnel on your own domain.
Spree backend must also be publicly reachable. The storefront's server-side fetches go to
SPREE_API_URL, but image URLs and a few other backend-served paths (e.g. the Apple Pay domain-verification file under/.well-known/apple-developer-merchantid-domain-association) are fetched by the browser directly and must resolve from the public internet. PointSPREE_API_URLat a hosted Spree (e.g.*.spree.sh,*.vendo.dev, your own staging) or expose your local Spree with anothercloudflared tunnel --url http://localhost:3000. When tunneling a local Rails app, allow the tunnel host, e.g.RAILS_DEVELOPMENT_HOSTS=.trycloudflare.comin the backend's.env.
npm run build
npm startsrc/
├── app/
│ └── [country]/[locale]/ # Localized routes
│ ├── account/ # Customer account pages
│ │ ├── addresses/ # Address management
│ │ ├── credit-cards/ # Saved payment methods
│ │ ├── orders/ # Order history
│ │ │ └── [id]/ # Order details
│ │ ├── register/ # Registration
│ │ └── profile/ # Profile settings
│ ├── cart/ # Shopping cart
│ ├── policies/ # Store policy pages
│ ├── products/ # Product listing
│ │ └── [slug]/ # Product details
│ ├── t/[...permalink]/ # Taxon/category pages
│ └── taxonomies/ # Category overview
├── components/
│ ├── layout/ # Header, Footer, CountrySwitcher
│ ├── products/ # ProductCard, ProductGrid, Filters
│ └── search/ # SearchBar
├── contexts/
│ ├── AuthContext.tsx # Client-side auth state
│ └── CartContext.tsx # Client-side cart state sync
└── lib/
├── spree/ # Spree integration helpers (auth, cookies, middleware, webhooks)
└── data/ # Server Actions
├── addresses.ts # Address CRUD operations
├── cart.ts # Cart operations
├── cookies.ts # Auth token management
├── countries.ts # Countries/regions list
├── credit-cards.ts # Payment methods
├── customer.ts # Auth & profile
├── orders.ts # Order history
├── policies.ts # Store policies
├── products.ts # Product queries
├── store.ts # Store configuration
└── taxonomies.ts # Categories/taxons
All data fetching is done through server actions in src/lib/data/. These call @spree/sdk directly, using helpers in src/lib/spree/ for auth cookies and locale resolution:
// Products — uses getLocaleOptions() for locale-aware reads
import { getProducts, getProduct, getProductFilters } from '@/lib/data/products'
const products = await getProducts({ limit: 12 })
const product = await getProduct('product-slug', { expand: ['variants', 'media'] })
const filters = await getProductFilters()
// Cart — uses getCartOptions()/requireCartId() for cart operations
import { getCart, addToCart, updateCartItem, removeCartItem } from '@/lib/data/cart'
const cart = await getCart()
await addToCart('var_xxx', 1)
await updateCartItem('li_xxx', 2)
await removeCartItem('li_xxx')
// Authentication — uses withAuthRefresh() for authenticated endpoints
import { login, register, logout, getCustomer } from '@/lib/data/customer'
const result = await login('user@example.com', 'password')
await register({
email: 'user@example.com',
password: 'password',
password_confirmation: 'password',
first_name: 'John',
last_name: 'Doe',
})
const customer = await getCustomer()
await logout()
// Addresses — uses withAuthRefresh() for customer data
import { getAddresses, createAddress, updateAddress, deleteAddress } from '@/lib/data/addresses'
const addresses = await getAddresses()
await createAddress({ first_name: 'John', ... })- User submits login form
- Server action calls
@spree/sdkto authenticate - JWT token is stored in an httpOnly cookie via
src/lib/spreecookie helpers - Subsequent requests use
withAuthRefresh()which reads the token automatically - Token is never accessible to client-side JavaScript
// src/lib/data/customer.ts
import { getClient, withAuthRefresh, setAccessToken, setRefreshToken } from '@/lib/spree'
export async function login(email: string, password: string) {
const result = await getClient().auth.login({ email, password })
await setAccessToken(result.token)
await setRefreshToken(result.refresh_token)
return { success: true, user: result.user }
}
export async function getCustomer() {
return withAuthRefresh(async (options) => {
return getClient().customer.get(options)
})
}The storefront supports multiple countries and currencies via URL segments:
/us/en/products # US Market, English language
/de/de/products # European Market, German language
/uk/en/products # UK Market, English
Use the CountrySwitcher component to change Markets.
The storefront uses Tailwind CSS. Customize the design by modifying:
tailwind.config.ts- Theme configurationsrc/app/globals.css- Global styles
All components are in src/components/ and can be customized or replaced as needed.
To customize API behavior, modify the server actions in src/lib/data/. These call @spree/sdk directly, using helpers in src/lib/spree/ for auth cookies and locale resolution.
Customer-facing emails (order confirmation, shipping notification, password reset) are rendered in the storefront using react-email and sent via Resend. The Spree backend delivers events to the storefront via webhooks.
-
Create a webhook endpoint in Spree Admin → Settings → Developers → Webhooks:
- Subscribe to:
order.completed,order.canceled,order.shipped,customer.password_reset_requested - Copy the secret key
- Subscribe to:
-
Add environment variables to
.env.local:
SPREE_WEBHOOK_SECRET=your_webhook_endpoint_secret_key
RESEND_API_KEY=re_your_resend_api_key # production only
EMAIL_FROM=Your Store <orders@your-domain.com> # production only- For local development, use Cloudflare Tunnel to expose your storefront:
brew install cloudflared
cloudflared tunnel --url http://localhost:3001Use the tunnel URL as the webhook endpoint URL in Spree Admin.
No RESEND_API_KEY needed in dev — emails are rendered to HTML files in .next/emails/ with a file:// link logged to the console.
Templates are in src/lib/emails/ as React components:
| Template | Event | Description |
|---|---|---|
order-confirmation.tsx |
order.completed |
Order placed with items, totals, addresses |
order-canceled.tsx |
order.canceled |
Cancellation notice |
shipment-shipped.tsx |
order.shipped |
Shipping notification with tracking link |
password-reset.tsx |
customer.password_reset_requested |
Password reset link |
npm run email:devOpens the react-email dev server with all templates and mock data for live preview.
Spree Backend → Webhook POST → /api/webhooks/spree → render email → send via Resend
(signed HMAC) (signature verified) (react-email) (or write to disk in dev)
The webhook route handler (src/app/api/webhooks/spree/route.ts) uses createWebhookHandler from src/lib/spree/webhooks — signature verification and event routing are handled automatically.
The easiest way to deploy is using Vercel:
- Push your code to GitHub
- Import the repository in Vercel
- Add environment variables:
SPREE_API_URLandSPREE_PUBLISHABLE_KEY(required)SPREE_WEBHOOK_SECRET,RESEND_API_KEY,EMAIL_FROM(for transactional emails)GTM_ID(optional — Google Tag Manager)SENTRY_DSN,SENTRY_ORG,SENTRY_PROJECT,SENTRY_AUTH_TOKEN(optional — for error tracking with readable stack traces)
- Deploy
MIT