To accept Paystack payments in Next.js App Router, create a Route Handler (app/api/pay/route.ts) that initializes a transaction with the Paystack API using your secret key. Return the authorization URL to the client, redirect the user to Paystack checkout, then verify the payment in a callback Route Handler or Server Action before granting value.
Before you start, make sure you have:
app/ directory, not pages/)If you are using the Pages Router instead, see Accept Payments with Paystack in Next.js Pages Router.
This guide uses TypeScript throughout. The patterns work the same in JavaScript; just remove the type annotations.
Create a new Next.js project or use an existing one:
npx create-next-app@latest paystack-nextjs --app --typescript
cd paystack-nextjs
Create a .env.local file in the project root with your Paystack keys:
PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxx
The PAYSTACK_SECRET_KEY is only accessible on the server (Route Handlers, Server Actions, server components). The NEXT_PUBLIC_ prefix makes the public key available in client components for the inline popup, but you should never expose the secret key to the browser.
No extra npm packages are needed. The Paystack API is a plain REST API, and Next.js includes fetch out of the box. If you want the inline popup, you will load the Paystack Inline.js script from a CDN.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Create a Route Handler at app/api/pay/route.ts. This endpoint receives the customer email and amount from your frontend, calls the Paystack Initialize Transaction endpoint, and returns the authorization URL.
// app/api/pay/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { email, amount } = await request.json();
// Generate a unique reference for this transaction
const reference = 'order_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
const paystackResponse = 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,
amount: Math.round(amount * 100), // Convert to kobo
reference,
callback_url: process.env.NEXT_PUBLIC_BASE_URL + '/payment/callback',
metadata: {
custom_fields: [
{
display_name: 'Payment For',
variable_name: 'payment_for',
value: 'Order ' + reference,
},
],
},
}),
}
);
const data = await paystackResponse.json();
if (!data.status) {
return NextResponse.json(
{ error: data.message },
{ status: 400 }
);
}
// TODO: Save reference + amount + email to your database here
return NextResponse.json({
authorization_url: data.data.authorization_url,
access_code: data.data.access_code,
reference: data.data.reference,
});
}
A few things to notice:
Authorization header uses string concatenation instead of template literals because this code lives inside a TypeScript template literal. In your actual project, you would use whichever style you prefer.Math.round(amount * 100) converts the display amount to kobo and avoids floating-point precision issues.callback_url tells Paystack where to redirect the customer after payment. We will build this page next.Create a client component that collects the email and amount, calls your Route Handler, and redirects to Paystack.
// app/checkout/page.tsx
'use client';
import { useState } from 'react';
export default function CheckoutPage() {
const [email, setEmail] = useState('');
const [amount, setAmount] = useState(1000); // 1000 NGN
const [loading, setLoading] = useState(false);
async function handlePay() {
setLoading(true);
const res = await fetch('/api/pay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, amount }),
});
const data = await res.json();
if (data.authorization_url) {
// Redirect to Paystack checkout
window.location.href = data.authorization_url;
} else {
alert(data.error || 'Something went wrong');
setLoading(false);
}
}
return (
<div style={{ maxWidth: 400, margin: '80px auto' }}>
<h1>Checkout</h1>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<p>Amount: NGN {amount.toLocaleString()}</p>
<button onClick={handlePay} disabled={loading || !email}>
{loading ? 'Redirecting...' : 'Pay Now'}
</button>
</div>
);
}
When the user clicks "Pay Now," the frontend calls your Route Handler, gets the authorization URL, and sends the user to Paystack's hosted checkout page. Paystack handles card collection, 3DS verification, bank transfers, and everything else.
If you want the customer to stay on your page while they pay, use Paystack's inline popup. You can initialize the transaction using a Server Action instead of a Route Handler.
First, create the Server Action:
// app/actions/paystack.ts
'use server';
export async function initializePayment(email: string, amount: number) {
const reference = 'order_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
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,
amount: Math.round(amount * 100),
reference,
}),
});
const data = await res.json();
if (!data.status) {
return { error: data.message };
}
// TODO: Save to database
return {
access_code: data.data.access_code,
reference: data.data.reference,
};
}
Then load Paystack Inline.js and open the popup from your client component:
// app/checkout-inline/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { initializePayment } from '../actions/paystack';
declare global {
interface Window {
PaystackPop: any;
}
}
export default function InlineCheckout() {
const [email, setEmail] = useState('');
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://js.paystack.co/v2/inline.js';
document.head.appendChild(script);
}, []);
async function handlePay() {
const result = await initializePayment(email, 5000);
if ('error' in result) {
alert(result.error);
return;
}
const popup = new window.PaystackPop();
popup.checkout({
accessCode: result.access_code,
onSuccess: async (transaction: { reference: string }) => {
// Verify the payment on your server
const verifyRes = await fetch(
'/api/verify?reference=' + transaction.reference
);
const verified = await verifyRes.json();
if (verified.success) {
window.location.href = '/payment/success';
}
},
onCancel: () => {
console.log('Customer closed the popup');
},
});
}
return (
<div style={{ maxWidth: 400, margin: '80px auto' }}>
<h1>Pay with Popup</h1>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button onClick={handlePay} disabled={!email}>Pay NGN 5,000</button>
</div>
);
}
The Server Action runs on the server, so your secret key never reaches the browser. The client receives only the access code, which is safe to use on the frontend. After the popup closes with a successful payment, you verify server-side before granting value.
Create a Route Handler at app/api/verify/route.ts to verify transactions. This handles both the redirect callback and the inline popup verification.
// app/api/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const reference = request.nextUrl.searchParams.get('reference');
if (!reference) {
return NextResponse.json(
{ error: 'Missing reference' },
{ status: 400 }
);
}
const paystackResponse = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
const data = await paystackResponse.json();
if (
data.status &&
data.data.status === 'success'
) {
const amountInKobo = data.data.amount;
const currency = data.data.currency;
const customerEmail = data.data.customer.email;
// TODO: Look up the order by reference in your database
// TODO: Confirm the amount and currency match what you expected
// TODO: Mark the order as paid (check if already paid to prevent double-granting)
// TODO: Deliver value (send confirmation email, unlock content, etc.)
return NextResponse.json({
success: true,
amount: amountInKobo / 100,
currency,
email: customerEmail,
reference: data.data.reference,
});
}
return NextResponse.json(
{ success: false, message: 'Payment not successful' },
{ status: 400 }
);
}
Three checks are non-negotiable: the transaction status must be "success", the amount must match what you stored when you initialized the transaction, and the currency must be correct. Skipping any of these opens you to underpayment or currency-swap exploits.
For the full verification pattern including webhook-vs-callback race conditions, see Verify Paystack Payments in Next.js App Router.
When Paystack redirects the customer back to your site after payment, the URL includes ?trxref=REFERENCE&reference=REFERENCE as query parameters. Create a page at app/payment/callback/page.tsx to handle this.
// app/payment/callback/page.tsx
import { redirect } from 'next/navigation';
interface Props {
searchParams: Promise<{ reference?: string; trxref?: string }>;
}
export default async function PaymentCallbackPage({ searchParams }: Props) {
const params = await searchParams;
const reference = params.reference || params.trxref;
if (!reference) {
redirect('/checkout?error=missing_reference');
}
// Verify server-side
const verifyRes = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
cache: 'no-store',
}
);
const data = await verifyRes.json();
if (data.status && data.data.status === 'success') {
// TODO: Mark order as paid in your database (if not already done by webhook)
return (
<div style={{ maxWidth: 500, margin: '80px auto', textAlign: 'center' }}>
<h1>Payment Successful</h1>
<p>Reference: {reference}</p>
<p>Amount: {data.data.currency} {(data.data.amount / 100).toLocaleString()}</p>
<a href="/">Back to Home</a>
</div>
);
}
return (
<div style={{ maxWidth: 500, margin: '80px auto', textAlign: 'center' }}>
<h1>Payment Failed</h1>
<p>Status: {data.data?.status || 'unknown'}</p>
<a href="/checkout">Try Again</a>
</div>
);
}
This callback page is a Server Component, so the verification call runs on the server. The customer never sees or touches the secret key. The cache: 'no-store' option ensures Next.js does not cache the verification response.
Remember: the redirect is not proof of payment. Someone could type the URL manually with a fake reference. The Paystack verify call is what confirms the payment actually happened.
Before switching from test keys to live keys, run through this checklist:
'use client' file, or any file that gets bundled for the browser. Route Handlers and Server Actions are safe. Server Components are safe.order.paid = true) and check it before fulfilling. Whichever fires first sets the flag; the second one skips.sk_test_ and pk_test_ with sk_live_ and pk_live_ in your production environment variables. Do not hardcode them.For a broader look at Paystack security best practices, read why you must never call the Paystack API from your frontend.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course