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 --app --src-dir
cd storefront
npm install @hanzo/commerce @hanzo/guiThe storefront is styled with @hanzo/gui, so the same components
render on the web, iOS and Android. Style lives in props on the component, not
in class names.
Configure 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 { Card, H1, H2, Image, Paragraph, XStack, YStack } from '@hanzo/gui'
import Link from 'next/link'
export default async function ProductsPage() {
const { products } = await commerce.products.list({
limit: 20,
expand: ['variants', 'images'],
})
return (
<YStack maxWidth={1280} width="100%" marginHorizontal="auto" padding="$4" gap="$8">
<H1>Products</H1>
{/* Stacks wrap instead of a CSS grid, so the same layout runs on native */}
<XStack flexWrap="wrap" gap="$6">
{products.map((product) => (
<Link key={product.id} href={`/products/${product.slug}`}>
<Card
bordered
padding="$4"
gap="$4"
width="100%"
$gtSm={{ width: 300 }}
hoverStyle={{ borderColor: '$borderColorHover' }}
pressStyle={{ scale: 0.98 }}
animation="quick"
>
{product.images?.[0] && (
<Image
source={{ uri: product.images[0].url }}
alt={product.name}
width="100%"
height={192}
borderRadius="$4"
objectFit="cover"
/>
)}
<H2 size="$5">{product.name}</H2>
<Paragraph theme="alt2">
{(product.price / 100).toFixed(2)} {product.currency}
</Paragraph>
</Card>
</Link>
))}
</XStack>
</YStack>
)
}Product Detail Page
Use generateStaticParams for static generation of product pages.
import { commerce } from '@/lib/commerce'
import { AddToCartButton } from './add-to-cart-button'
import { H1, Image, Label, Paragraph, Select, XStack, YStack } from '@hanzo/gui'
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 (
<YStack maxWidth={900} width="100%" marginHorizontal="auto" padding="$4">
<XStack flexWrap="wrap" gap="$8">
<YStack flex={1} minWidth={280}>
{product.images?.[0] && (
<Image
source={{ uri: product.images[0].url }}
alt={product.name}
width="100%"
aspectRatio={1}
borderRadius="$4"
/>
)}
</YStack>
<YStack flex={1} minWidth={280} gap="$4">
<H1>{product.name}</H1>
<Paragraph size="$8">
{(product.price / 100).toFixed(2)} {product.currency}
</Paragraph>
<Paragraph theme="alt2">{product.description}</Paragraph>
{product.variants && product.variants.length > 0 && (
<YStack gap="$2">
<Label>Variant</Label>
<Select defaultValue={product.variants[0].id}>
<Select.Trigger>
<Select.Value placeholder="Choose a variant" />
</Select.Trigger>
<Select.Content>
<Select.Viewport>
{product.variants.map((v, i) => (
<Select.Item key={v.id} index={i} value={v.id}>
<Select.ItemText>
{v.title} -- {(v.price / 100).toFixed(2)} {product.currency}
</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select>
</YStack>
)}
<AddToCartButton productId={product.id} />
</YStack>
</XStack>
</YStack>
)
}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'
import { Button } from '@hanzo/gui'
export function AddToCartButton({ productId }: { productId: string }) {
const { addItem } = useCart()
return (
<Button theme="active" size="$5" width="100%" onPress={() => addItem(productId)}>
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'
import { H1, Paragraph, YStack } from '@hanzo/gui'
interface Props {
searchParams: { session: string }
}
export default async function ConfirmationPage({ searchParams }: Props) {
const order = await commerce.orders.getBySession(searchParams.session)
return (
<YStack
maxWidth={640}
width="100%"
marginHorizontal="auto"
paddingHorizontal="$4"
paddingVertical="$10"
alignItems="center"
gap="$2"
>
<H1>Order Confirmed</H1>
<Paragraph theme="alt2">Order #{order.displayId}</Paragraph>
<Paragraph theme="alt2">
Total: {(order.total / 100).toFixed(2)} {order.currency}
</Paragraph>
</YStack>
)
}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?