CommerceRecipes
Next.js Storefront
Build a production-ready storefront with Next.js 14 and @hanzo/commerce
Build a fully functional e-commerce storefront using Next.js 14 App Router, React Server Components, and the @hanzo/commerce SDK.
What You Will Build
- Product listing page with search and filtering
- Product detail page with variant selection
- Persistent shopping cart
- Checkout flow with Stripe payments
- Order confirmation page
Prerequisites
- Node.js 18+
- A Hanzo API key
- A Stripe test key (for checkout)
Create the Project
npx create-next-app@latest storefront --typescript --tailwind --app --src-dir
cd storefront
npm install @hanzo/commerceConfigure the SDK
Create a shared Commerce client that runs on the server.
import { Commerce } from '@hanzo/commerce'
export const commerce = new Commerce({
apiKey: process.env.HANZO_API_KEY!,
environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
})Add your keys to .env.local:
HANZO_API_KEY=your_api_key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...Product Listing Page
Fetch products in a Server Component -- no client-side loading spinners needed.
import { commerce } from '@/lib/commerce'
import Link from 'next/link'
export default async function ProductsPage() {
const { products } = await commerce.products.list({
limit: 20,
expand: ['variants', 'images'],
})
return (
<main className="mx-auto max-w-7xl px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Products</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{products.map((product) => (
<Link
key={product.id}
href={`/products/${product.slug}`}
className="group rounded-lg border p-4 hover:shadow-md transition"
>
{product.images?.[0] && (
<img
src={product.images[0].url}
alt={product.name}
className="w-full h-48 object-cover rounded mb-4"
/>
)}
<h2 className="font-semibold">{product.name}</h2>
<p className="text-gray-600">
{(product.price / 100).toFixed(2)} {product.currency}
</p>
</Link>
))}
</div>
</main>
)
}Product Detail Page
Use generateStaticParams for static generation of product pages.
import { commerce } from '@/lib/commerce'
import { AddToCartButton } from './add-to-cart-button'
import { notFound } from 'next/navigation'
interface Props {
params: { slug: string }
}
export async function generateStaticParams() {
const { products } = await commerce.products.list({ limit: 100 })
return products.map((p) => ({ slug: p.slug }))
}
export default async function ProductPage({ params }: Props) {
const product = await commerce.products.getBySlug(params.slug, {
expand: ['variants', 'images'],
})
if (!product) notFound()
return (
<main className="mx-auto max-w-4xl px-4 py-8">
<div className="grid md:grid-cols-2 gap-8">
<div>
{product.images?.[0] && (
<img
src={product.images[0].url}
alt={product.name}
className="w-full rounded-lg"
/>
)}
</div>
<div>
<h1 className="text-3xl font-bold mb-2">{product.name}</h1>
<p className="text-2xl mb-4">
{(product.price / 100).toFixed(2)} {product.currency}
</p>
<p className="text-gray-600 mb-6">{product.description}</p>
{product.variants && product.variants.length > 0 && (
<div className="mb-4">
<label className="block font-medium mb-2">Variant</label>
<select className="border rounded px-3 py-2 w-full">
{product.variants.map((v) => (
<option key={v.id} value={v.id}>
{v.title} -- {(v.price / 100).toFixed(2)} {product.currency}
</option>
))}
</select>
</div>
)}
<AddToCartButton productId={product.id} />
</div>
</div>
</main>
)
}Cart Context
Manage cart state on the client with a React context.
'use client'
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
interface CartItem {
productId: string
variantId?: string
quantity: number
}
interface CartContextValue {
cartId: string | null
items: CartItem[]
addItem: (productId: string, variantId?: string) => Promise<void>
removeItem: (productId: string) => Promise<void>
itemCount: number
}
const CartContext = createContext<CartContextValue | null>(null)
export function CartProvider({ children }: { children: ReactNode }) {
const [cartId, setCartId] = useState<string | null>(null)
const [items, setItems] = useState<CartItem[]>([])
const addItem = useCallback(async (productId: string, variantId?: string) => {
const res = await fetch('/api/cart/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cartId, productId, variantId }),
})
const data = await res.json()
setCartId(data.cartId)
setItems(data.items)
}, [cartId])
const removeItem = useCallback(async (productId: string) => {
const res = await fetch('/api/cart/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cartId, productId }),
})
const data = await res.json()
setItems(data.items)
}, [cartId])
const itemCount = items.reduce((sum, i) => sum + i.quantity, 0)
return (
<CartContext.Provider value={{ cartId, items, addItem, removeItem, itemCount }}>
{children}
</CartContext.Provider>
)
}
export function useCart() {
const ctx = useContext(CartContext)
if (!ctx) throw new Error('useCart must be used within CartProvider')
return ctx
}Add to Cart Button
'use client'
import { useCart } from '@/lib/cart-context'
export function AddToCartButton({ productId }: { productId: string }) {
const { addItem } = useCart()
return (
<button
onClick={() => addItem(productId)}
className="w-full bg-black text-white py-3 rounded-lg hover:bg-gray-800 transition"
>
Add to Cart
</button>
)
}Cart API Route
Server-side route handler that talks to the Commerce API.
import { commerce } from '@/lib/commerce'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const { cartId, productId, variantId } = await req.json()
let cart
if (cartId) {
cart = await commerce.carts.addItem(cartId, {
productId,
variantId,
quantity: 1,
})
} else {
cart = await commerce.carts.create({
items: [{ productId, variantId, quantity: 1 }],
})
}
return NextResponse.json({
cartId: cart.id,
items: cart.items,
})
}Checkout Integration
Create a checkout session and redirect to Stripe.
import { commerce } from '@/lib/commerce'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const { cartId } = await req.json()
const session = await commerce.checkout.create({
cartId,
successUrl: `${process.env.NEXT_PUBLIC_URL}/order/confirmation?session={SESSION_ID}`,
cancelUrl: `${process.env.NEXT_PUBLIC_URL}/cart`,
})
return NextResponse.json({ url: session.url })
}Order Confirmation
import { commerce } from '@/lib/commerce'
interface Props {
searchParams: { session: string }
}
export default async function ConfirmationPage({ searchParams }: Props) {
const order = await commerce.orders.getBySession(searchParams.session)
return (
<main className="mx-auto max-w-2xl px-4 py-16 text-center">
<h1 className="text-3xl font-bold mb-4">Order Confirmed</h1>
<p className="text-gray-600 mb-2">Order #{order.displayId}</p>
<p className="text-gray-600">
Total: {(order.total / 100).toFixed(2)} {order.currency}
</p>
</main>
)
}Testing
Run the development server and verify each page:
npm run dev- Open
http://localhost:3000/products-- products should render server-side - Click a product -- detail page with variant selector
- Click "Add to Cart" -- cart count updates
- Proceed to checkout -- redirects to Stripe test page
- Use Stripe test card
4242 4242 4242 4242-- redirects to confirmation
Next Steps
- Add multi-currency support for international customers
- Implement subscription products for recurring revenue
- Deploy the storefront to Vercel
How is this guide?
Last updated on