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 Provider | Best For | Fee |
|---|---|---|
| Stripe | US, Europe, global cards | 2.9% + $0.30 |
| Paystack | Nigeria, Ghana, Kenya | 1.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
| Tool | Purpose | Cost |
|---|---|---|
| Next.js 14 | Full-stack framework | Free |
| Supabase | Database + Auth + Storage | Free (500MB) |
| Cloudflare Pages | Hosting | Free |
| Cloudinary | Image hosting + optimisation | Free (25GB) |
| Stripe | Global payments | No monthly fee |
| Paystack | African payments | No 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 })
},
