Build a Production E-Commerce Store With Next.js, Stripe, and Paystack
Build17 min read·April 10, 2026·--

Build a Production E-Commerce Store With Next.js, Stripe, and Paystack

A complete e-commerce store that accepts payments from customers worldwide including Africa. Product listings, cart, checkout, order management, and admin — all built step by step.

@
@kivorablog
April 10, 2026
Share

The Two-Payment Strategy


Building an e-commerce store in 2026 without supporting both global and African payment methods leaves money on the table. This guide implements both:


Payment ProviderBest ForFee
StripeUS, Europe, global cards2.9% + $0.30
PaystackNigeria, Ghana, Kenya1.5% + ₦100 (capped at ₦2,000)

The store auto-detects the user's country (via IP) and shows the appropriate payment option. Nigerian user sees Paystack. American user sees Stripe. Both work seamlessly.




Free Stack


ToolPurposeCost
Next.js 14Full-stack frameworkFree
SupabaseDatabase + Auth + StorageFree (500MB)
Cloudflare PagesHostingFree
CloudinaryImage hosting + optimisationFree (25GB)
StripeGlobal paymentsNo monthly fee
PaystackAfrican paymentsNo monthly fee



Database Schema


-- Products
create table products (
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  description text,
  price_usd   decimal(10,2) not null,
  price_ngn   integer,                  -- In kobo
  images      text[],                   -- Cloudinary URLs
  category    text,
  stock       integer default 0,
  active      boolean default true,
  created_at  timestamptz default now()
);

-- Orders
create table orders (
  id               uuid primary key default gen_random_uuid(),
  user_id          uuid references auth.users(id) on delete set null,
  email            text not null,
  items            jsonb not null,       -- [{productId, name, price, quantity}]
  subtotal         decimal(10,2),
  total            decimal(10,2),
  currency         text default 'USD',
  payment_provider text,                 -- 'stripe' | 'paystack'
  payment_ref      text unique,
  status           text default 'pending',
  shipping_address jsonb,
  created_at       timestamptz default now()
);

-- RLS
alter table products enable row level security;
alter table orders enable row level security;

create policy "products are public" on products for select using (active = true);
create policy "users own orders"    on orders   for select using (auth.uid() = user_id);
create policy "service full access" on orders   for all using (auth.role() = 'service_role');



Product Listing Page


// app/page.jsx
import { supabaseAdmin } from '@/lib/supabase'
import ProductCard from '@/components/ProductCard'

export default async function HomePage() {
  const { data: products } = await supabaseAdmin
    .from('products')
    .select('*')
    .eq('active', true)
    .order('created_at', { ascending: false })

  return (
    <main>
      <h1 className="text-3xl font-bold mb-8">Our Products</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
        {(products || []).map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </main>
  )
},



Cart (Using Zustand for State Management)


npm install zustand

// lib/cart.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useCart = create(
  persist(
    (set, get) => ({
      items: [],

      addItem: (product, quantity = 1) => {
        const items      = get().items
        const existing   = items.find(i => i.id === product.id)

        if (existing) {
          set({ items: items.map(i =>
            i.id === product.id
              ? { ...i, quantity: i.quantity + quantity }
              : i
          )})
        } else {
          set({ items: [...items, { ...product, quantity }] })
        }
      },

      removeItem: (productId) => {
        set({ items: get().items.filter(i => i.id !== productId) })
      },

      updateQuantity: (productId, quantity) => {
        if (quantity <= 0) { get().removeItem(productId); return }
        set({ items: get().items.map(i =>
          i.id === productId ? { ...i, quantity } : i
        )})
      },

      clearCart: () => set({ items: [] }),

      get total() {
        return get().items.reduce((sum, item) => sum + (item.price_usd * item.quantity), 0)
      },

      get itemCount() {
        return get().items.reduce((sum, item) => sum + item.quantity, 0)
      }
    }),
    { name: 'kivora-cart' }
  )
)



The Checkout Flow


// app/api/checkout/route.js
import { supabaseAdmin } from '@/lib/supabase'

export async function POST(req) {
  const { items, email, shippingAddress, currency } = await req.json()

  if (!items?.length || !email) {
    return Response.json({ error: 'Missing required fields' }, { status: 400 })
  }

  // Calculate totals
  const subtotal = items.reduce((sum, item) => sum + (item.price_usd * item.quantity), 0)
  const shipping = subtotal > 50 ? 0 : 5.99
  const total    = subtotal + shipping

  // Create pending order
  const { data: order, error } = await supabaseAdmin
    .from('orders')
    .insert({
      email,
      items,
      subtotal,
      total,
      currency,
      shipping_address: shippingAddress,
      status: 'pending',
    })
    .select()
    .single()

  if (error) return Response.json({ error: 'Failed to create order' }, { status: 500 })

  // Redirect to appropriate payment provider
  if (currency === 'NGN') {
    return initializePaystack(order)
  } else {
    return initializeStripe(order)
  }
},

async function initializeStripe(order) {
  const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    mode: 'payment',
    customer_email: order.email,
    line_items: order.items.map(item => ({
      price_data: {
        currency:     'usd',
        product_data: { name: item.name },
        unit_amount:  Math.round(item.price_usd * 100),
      },
      quantity: item.quantity,
    })),
    metadata:    { orderId: order.id },
    success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/order/success?orderId=${order.id}`,
    cancel_url:  `${process.env.NEXT_PUBLIC_SITE_URL}/checkout`,
  })

  return Response.json({ checkoutUrl: session.url })
},

async function initializePaystack(order) {
  const totalInKobo = Math.round(order.total * 1500 * 100) // Convert USD → NGN → kobo

  const res = await fetch('https://api.paystack.co/transaction/initialize', {
    method:  'POST',
    headers: {
      Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email:        order.email,
      amount:       totalInKobo,
      reference:    order.id,
      metadata:     { orderId: order.id },
      callback_url: `${process.env.NEXT_PUBLIC_SITE_URL}/order/success?orderId=${order.id}`,
    }),
  })

  const data = await res.json()
  return Response.json({ checkoutUrl: data.data.authorization_url })
},
Read more on Kivora Blog

Read more on Kivora Blog

Get started →