Ponkan
Education

Building a Simple E-Commerce App with the Ponkan API

Build a fully functional storefront with product listing, shopping cart, and checkout using the Ponkan API and Next.js.

DM

Daniel Moreno

March 16, 2026

Building a Simple E-Commerce App with the Ponkan API

Ponkan gives you a complete commerce backend out of the box — products, customers, orders, payments, and checkout. In this guide, we'll build a simple e-commerce storefront on top of it using Next.js.

We'll cover fetching products, building a client-side shopping cart, creating checkout links on the fly, and handling the post-purchase flow. By the end, you'll have a working storefront that accepts real payments.

Architecture overview

Our storefront is a standard Next.js app that talks to Ponkan's REST API:

The key design decisions:

  • Server components fetch product data directly from the API
  • Client components manage the shopping cart in localStorage
  • API routes proxy checkout link creation to keep your token server-side
  • The actual payment happens on Ponkan's hosted checkout page

Setting up the API client

First, create a typed API client. This handles authentication, error parsing, and gives you clean methods to call:

// lib/block.ts const API_URL = process.env.BLOCK_API_URL || "http://localhost:3000"; const API_TOKEN = process.env.BLOCK_API_TOKEN || ""; async function request<T>( path: string, options: { method?: string; body?: Record<string, unknown> } = {} ): Promise<T> { const { method = "GET", body } = options; const headers: Record<string, string> = { Authorization: `Bearer ${API_TOKEN}`, Accept: "application/json", }; if (body) { headers["Content-Type"] = "application/json"; } const res = await fetch(`${API_URL}/api/v1${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, cache: "no-store", }); if (!res.ok) { const text = await res.text(); throw new Error(`API error: ${res.status} — ${text.slice(0, 200)}`); } return res.json(); }

Then add typed methods for the resources you need:

export interface Product { id: string; name: string; description: string | null; price_cents: number; currency: string; status: string; } export interface PaginatedResponse<T> { data: T[]; meta: { page: number; per_page: number; total: number; has_more: boolean }; } export interface SingleResponse<T> { data: T; } export const block = { products: { list() { return request<PaginatedResponse<Product>>("/products"); }, get(id: string) { return request<SingleResponse<Product>>(`/products/${id}`); }, }, checkoutLinks: { create(body: Record<string, unknown>) { return request<SingleResponse<{ slug: string }>>("/checkout_links", { method: "POST", body, }); }, }, };

Displaying products

Fetch products in a server component — no loading spinners needed:

// app/products/page.tsx import { block, formatPrice } from "@/lib/block"; export default async function ProductsPage() { const { data: products } = await block.products.list(); return ( <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> {products.map((product) => ( <div key={product.id} className="rounded-lg border p-6"> <h3 className="text-lg font-semibold">{product.name}</h3> <p className="mt-1 text-gray-600">{product.description}</p> <p className="mt-4 text-xl font-bold"> {formatPrice(product.price_cents, product.currency)} </p> <AddToCartButton product={product} /> </div> ))} </div> ); }

The formatPrice helper converts cents to a formatted currency string:

export function formatPrice(cents: number, currency: string = "usd"): string { return new Intl.NumberFormat("en-US", { style: "currency", currency: currency.toUpperCase(), }).format(cents / 100); }

Building the shopping cart

The cart lives entirely in the browser using localStorage. No server state needed:

// lib/cart.ts "use client"; import { Product } from "./block"; export interface CartItem { product: Product; quantity: number; } const CART_KEY = "storefront-cart"; export function getCart(): CartItem[] { if (typeof window === "undefined") return []; const raw = localStorage.getItem(CART_KEY); return raw ? JSON.parse(raw) : []; } export function addToCart(product: Product): CartItem[] { const items = getCart(); const existing = items.find((item) => item.product.id === product.id); if (existing) { existing.quantity += 1; } else { items.push({ product, quantity: 1 }); } localStorage.setItem(CART_KEY, JSON.stringify(items)); return items; } export function removeFromCart(productId: string): CartItem[] { const items = getCart().filter((item) => item.product.id !== productId); localStorage.setItem(CART_KEY, JSON.stringify(items)); return items; } export function getCartTotal(items: CartItem[]): number { return items.reduce( (sum, item) => sum + item.product.price_cents * item.quantity, 0 ); } export function clearCart(): void { localStorage.removeItem(CART_KEY); }

The checkout flow

This is where things get interesting. When the customer clicks "Checkout", we:

  1. Send the cart items to our own API route
  2. Our API route creates a checkout link via Ponkan
  3. We redirect the customer to the hosted checkout page

The API route

This keeps your API token server-side — never expose it to the browser:

// app/api/checkout/route.ts import { NextRequest, NextResponse } from "next/server"; import { block } from "@/lib/block"; const API_URL = process.env.BLOCK_API_URL || "http://localhost:3000"; export async function POST(request: NextRequest) { const { items } = await request.json(); if (!items?.length) { return NextResponse.json({ error: "Cart is empty" }, { status: 400 }); } const origin = request.headers.get("origin") || "http://localhost:3001"; const productIds = items.map( (item: { product_id: string }) => item.product_id ); const res = await block.checkoutLinks.create({ label: `Storefront order ${Date.now()}`, product_ids: productIds, success_url: `${origin}/order/success`, cancel_url: `${origin}/cart`, }); return NextResponse.json({ url: `${API_URL}/checkout/${res.data.slug}`, }); }

The checkout button

On the cart page, a button triggers the checkout:

"use client"; import { useState } from "react"; import { getCart, clearCart } from "@/lib/cart"; export function CheckoutButton() { const [loading, setLoading] = useState(false); async function handleCheckout() { setLoading(true); const cart = getCart(); const res = await fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: cart.map((item) => ({ product_id: item.product.id, quantity: item.quantity, })), }), }); const { url } = await res.json(); clearCart(); window.location.href = url; } return ( <button onClick={handleCheckout} disabled={loading}> {loading ? "Redirecting..." : "Checkout"} </button> ); }

The customer is redirected to Ponkan's hosted checkout page where they complete payment. After paying, they're sent back to your success URL.

After the purchase

Success page

When the customer returns to your success page, the order has already been created in Ponkan. You can display a confirmation:

// app/order/success/page.tsx export default function OrderSuccessPage() { return ( <div className="text-center py-20"> <h1 className="text-2xl font-bold">Thank you for your purchase!</h1> <p className="mt-2 text-gray-600"> Your order has been confirmed. Check your email for details. </p> </div> ); }

Fetching orders

You can query orders from the API to build an order history page:

curl https://yourapp.com/api/v1/orders?sort=newest \ -H "Authorization: Bearer YOUR_TOKEN"

Each order includes items, payment status, amounts, and timestamps.

Adding discount codes

Ponkan supports discount codes out of the box. Create a discount via the API:

curl -X POST https://yourapp.com/api/v1/discounts \ -H "Authorization: Bearer YOUR_TOKEN" \ -d code="LAUNCH20" \ -d type="percentage" \ -d value=20 \ -d starts_at="2026-03-01" \ -d ends_at="2026-04-01"

Customers can enter the code at checkout. The discount is applied automatically.

Environment variables

Your .env.local file needs two values:

BLOCK_API_URL=https://yourapp.com BLOCK_API_TOKEN=sk_live_your_token_here

Never commit your API token to version control. Use environment variables in your deployment platform.

The full picture

With just a few files, you have a complete e-commerce app:

FilePurpose
lib/block.tsTyped API client
lib/cart.tsClient-side cart with localStorage
app/products/page.tsxProduct listing (server component)
app/cart/page.tsxCart page with checkout button
app/api/checkout/route.tsProxy for checkout link creation
app/order/success/page.tsxPost-purchase confirmation

Ponkan handles the hard parts — payment processing, order creation, tax calculation, and receipt generation. Your storefront just needs to display products and redirect to checkout.

What's next

This guide covers the basics, but you can extend it further:

  • Add customer accounts using Ponkan's customer session API for order history and license key retrieval
  • Implement webhook listeners to sync order data in real-time
  • Use the invoicing API for B2B billing workflows
  • Create shareable checkout links for marketing campaigns

The example storefront in our repository includes all of these features — it's a great reference for building production-ready commerce experiences on top of Ponkan.

Education