Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 28 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ A modern, headless e-commerce storefront built with Next.js 16, React 19, and [S
- **TypeScript** - Full type safety
- **Sentry** - Error tracking and performance monitoring with source maps
- [@spree/sdk](https://spreecommerce.org/docs/developer/sdk/quickstart) - Official Spree Commerce SDK
- [@spree/next](https://spreecommerce.org/docs/developer/storefront/nextjs/spree-next-package) - Server actions, caching, and cookie-based auth
- [@spree/next](https://spreecommerce.org/docs/developer/storefront/nextjs/spree-next-package) - Cookie-based auth, middleware, and webhook helpers

## Architecture

Expand All @@ -41,8 +41,8 @@ This starter follows a **server-first pattern**:
4. **Cache Revalidation** - Uses Next.js cache tags for efficient updates

```
Browser → Server Action → Spree API
(with httpOnly cookies)
Browser → Server Action → @spree/sdk → Spree API
(with httpOnly cookies via @spree/next helpers)
```

## Getting Started
Expand Down Expand Up @@ -149,25 +149,25 @@ src/

## Server Actions

All data fetching is done through server actions in `src/lib/data/`:
All data fetching is done through server actions in `src/lib/data/`. These call `@spree/sdk` directly, using `@spree/next` helpers for auth cookies and locale resolution:

```typescript
// Products
// 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', 'images'] })
const filters = await getProductFilters({ taxon_id: 'txn_xxx' })
const product = await getProduct('product-slug', { expand: ['variants', 'media'] })
const filters = await getProductFilters()

// Cart
// 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
// Authentication — uses withAuthRefresh() for authenticated endpoints
import { login, register, logout, getCustomer } from '@/lib/data/customer'

const result = await login('user@example.com', 'password')
Expand All @@ -181,29 +181,35 @@ await register({
const customer = await getCustomer()
await logout()

// Addresses
// Addresses — uses withAuthRefresh() for customer data
import { getAddresses, createAddress, updateAddress, deleteAddress } from '@/lib/data/addresses'

const addresses = await getAddresses()
await createAddress({ firstname: 'John', ... })
await createAddress({ first_name: 'John', ... })
```

## Authentication Flow

1. User submits login form
2. Server action calls Spree API with credentials
3. JWT token is stored in an httpOnly cookie
4. Subsequent requests include the token automatically
2. Server action calls `@spree/sdk` to authenticate
3. JWT token is stored in an httpOnly cookie via `@spree/next` cookie helpers
4. Subsequent requests use `withAuthRefresh()` which reads the token automatically
5. Token is never accessible to client-side JavaScript

```typescript
// src/lib/data/cookies.ts
export async function setAuthToken(token: string) {
const cookieStore = await cookies()
cookieStore.set('_spree_jwt', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
// src/lib/data/customer.ts
import { getClient, withAuthRefresh, setAccessToken, setRefreshToken } from '@spree/next'

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)
})
}
```
Expand Down Expand Up @@ -235,7 +241,7 @@ All components are in `src/components/` and can be customized or replaced as nee

### Data Layer

To customize API behavior, modify the server actions in `src/lib/data/`.
To customize API behavior, modify the server actions in `src/lib/data/`. These call `@spree/sdk` directly, using `@spree/next` helpers for auth cookies and locale resolution.

## Transactional Emails

Expand Down
45 changes: 35 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
"@next/third-parties": "^16.1.6",
"@react-email/components": "^1.0.10",
"@sentry/nextjs": "^10.38.0",
"@spree/next": "^0.18.0",
"@spree/sdk": "^0.18.0",
"@spree/next": "^0.19.0",
"@spree/sdk": "^0.19.1",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"class-variance-authority": "^0.7.1",
Expand Down
4 changes: 3 additions & 1 deletion src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ export default function CheckoutPage({ params }: CheckoutPageProps) {
}, []);

// Track cart key for sidebar updates
const cartKey = cart ? `${cart.id}-${cart.updated_at}` : null;
const cartKey = cart
? `${cart.id}-${cart.total}-${cart.total_quantity}`
: null;
const prevOrderKeyRef = useRef(cartKey);

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Media, Product, Variant } from "@spree/sdk";
import { CircleCheckBig, CircleX, Loader2, ShoppingBag } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { MediaGallery } from "@/components/products/MediaGallery";
import { ProductMetafields } from "@/components/products/ProductMetafields";
import { ProductCustomFields } from "@/components/products/ProductCustomFields";
import { VariantPicker } from "@/components/products/VariantPicker";
import { Button } from "@/components/ui/button";
import { QuantityPicker } from "@/components/ui/quantity-picker";
Expand Down Expand Up @@ -221,8 +221,8 @@ export function ProductDetails({ product, basePath }: ProductDetailsProps) {
</div>
)}

{/* Metafields */}
<ProductMetafields metafields={product.metafields} />
{/* Custom Fields */}
<ProductCustomFields customFields={product.custom_fields} />

{/* Product Details */}
<div className="mt-8 border-t pt-8">
Expand Down
Loading
Loading