Next.js Checkout Flow
Build a complete payment checkout with Next.js App Router, server actions, and Wajub hosted payment page — production-ready in minutes.
This recipe walks through building a complete checkout flow with Next.js 14+ App Router. You'll create a server action that initializes a payment and redirects the user to Wajub's hosted payment page, plus a return handler that verifies the transaction.
This recipe implements Mode A — Redirect
For the architecture principle behind this flow, or for the Embedded / Custom fields modes instead, see React / Next.js — three modes. This page is the full, copy-paste tutorial for Mode A specifically.
Prerequisites
- A Wajub account with sandbox API keys
- Node.js 18+ and a Next.js project
npx create-next-app@latest my-store --typescript --app
cd my-store
npm install @wajub/nodeThis recipe uses the official @wajub/node server SDK. You can also call the REST API directly with fetch if you prefer.
1. Set up environment variables
Create .env.local:
WAJUB_PUBLIC_KEY=pk_test.xxxxxxxxxxxxxxxx
WAJUB_SECRET_KEY=sk_test.2a7c5e91b0d34f68xxxxxxxxxxxx3. Initialize the Wajub SDK server-side
Create lib/wajub.ts:
import { Wajub } from '@wajub/node';
export const wajub = new Wajub({
privateKey: process.env.WAJUB_SECRET_KEY!,
});4. Create a checkout server action
Create app/actions/checkout.ts:
'use server';
import { redirect } from 'next/navigation';
import { wajub } from '@/lib/wajub';
export async function checkout(formData: FormData) {
const amount = Number(formData.get('amount'));
const email = formData.get('email') as string;
const orderId = `ORDER-${Date.now()}`;
const payment = await wajub.payments.create({
amount,
currency: 'XAF',
customer: { email },
reference: orderId,
description: `Order ${orderId}`,
callback: `${process.env.NEXT_PUBLIC_APP_URL}/payment/return`,
metadata: { order_id: orderId },
});
redirect(payment.authorization_url);
}Using fetch instead of the SDK?
If you prefer raw fetch, the Authorization header must be your private key (sk_test.…),
not the public key — payment initialization is always server-side.
6. Create the checkout page
Create app/checkout/page.tsx:
import { checkout } from '@/app/actions/checkout';
export default function CheckoutPage() {
return (
<main className="mx-auto max-w-md p-6">
<h1 className="text-2xl font-bold">Checkout</h1>
<form action={checkout} className="mt-6 space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
type="email"
name="email"
id="email"
required
className="mt-1 block w-full rounded-md border px-3 py-2"
placeholder="customer@example.com"
/>
</div>
<div>
<label htmlFor="amount" className="block text-sm font-medium">
Amount (XAF)
</label>
<input
type="number"
name="amount"
id="amount"
required
min={100}
className="mt-1 block w-full rounded-md border px-3 py-2"
placeholder="25000"
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600"
>
Pay with Wajub
</button>
</form>
</main>
);
}7. Handle the return
Create app/payment/return/page.tsx:
export default async function PaymentReturnPage({
searchParams,
}: {
searchParams: { reference?: string; status?: string };
}) {
if (!searchParams.reference) {
return <p>No transaction reference found.</p>;
}
const res = await fetch(`https://api.wajub.com/payments/${searchParams.reference}`, {
headers: { Authorization: process.env.WAJUB_SECRET_KEY! },
});
if (!res.ok) {
return <p className="text-danger">Failed to verify payment.</p>;
}
const { transaction } = await res.json();
return (
<main className="mx-auto max-w-md p-6 text-center">
{transaction.status === 'succeeded' ? (
<>
<h1 className="text-2xl font-bold text-success">Payment successful!</h1>
<p className="mt-2 text-muted-foreground">
ID: {transaction.id}
<br />
Amount: {transaction.amount} {transaction.currency}
</p>
</>
) : (
<h1 className="text-2xl font-bold text-warning">
Payment pending — we'll confirm via webhook
</h1>
)}
</main>
);
}Always verify server-side
Never trust the status query parameter on its own. Always call GET /payments/{id} server-side
to confirm the transaction before fulfilling the order.
8. Add webhook handling (production)
For production, add a webhook endpoint that listens for payment.succeeded events. Signature
verification is security-critical and documented once — see Webhook signature
verification for the full HMAC algorithm and
the constructEvent SDK helper. The route itself, once you have a verifySignature (or
wajub.webhooks.constructEvent) helper:
import { NextRequest, NextResponse } from 'next/server';
import { verifySignature } from '@/lib/verify-webhook'; // see signature verification docs
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('x-wajub-signature');
const timestamp = req.headers.get('x-wajub-timestamp');
if (!signature || !timestamp || !verifySignature(rawBody, timestamp, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
const event = JSON.parse(rawBody);
switch (event.event) {
case 'payment.succeeded':
await fulfillOrder(event.data.id);
break;
case 'payment.failed':
await handleFailedPayment(event.data.id);
break;
}
return NextResponse.json({ received: true }, { status: 200 });
}
async function fulfillOrder(id: string) {
// Fulfill the order in your database
console.log(`Order ${id} paid — fulfilling...`);
}
async function handleFailedPayment(id: string) {
// Notify the customer, etc.
console.log(`Payment ${id} failed.`);
}Next steps
Related pages
Was this page helpful?