Hanzo

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/gui

The 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
  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?

On this page