Hanzo
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/commerce

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 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
  1. Open http://localhost:3000/products -- products should render server-side
  2. Click a product -- detail page with variant selector
  3. Click "Add to Cart" -- cart count updates
  4. Proceed to checkout -- redirects to Stripe test page
  5. Use Stripe test card 4242 4242 4242 4242 -- redirects to confirmation

Next Steps

How is this guide?

Last updated on

On this page