To accept Paystack payments in Next.js Pages Router, create an API route (pages/api/pay.ts) that initializes a transaction with the Paystack API. Return the authorization URL to your frontend, redirect the user to Paystack checkout, then verify the payment in a callback API route or using getServerSideProps before granting value.
Before you start, you need:
pages/ directory)If your project uses the App Router (app/ directory), see Accept Payments with Paystack in Next.js App Router instead.
Create a fresh Next.js project with the Pages Router or use your existing one:
npx create-next-app@latest paystack-pages --typescript
cd paystack-pages
Add your Paystack keys to .env.local:
PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_BASE_URL=http://localhost:3000
Variables without the NEXT_PUBLIC_ prefix are only available on the server (API routes, getServerSideProps, getStaticProps). The secret key stays server-side. The public key is needed only if you use the inline popup.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Create an API route at pages/api/pay.ts:
// pages/api/pay.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { email, amount } = req.body;
if (!email || !amount) {
return res.status(400).json({ error: 'Email and amount are required' });
}
const reference = 'order_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
const paystackRes = 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,
callback_url: process.env.NEXT_PUBLIC_BASE_URL + '/payment/callback',
}),
}
);
const data = await paystackRes.json();
if (!data.status) {
return res.status(400).json({ error: data.message });
}
// TODO: Save reference, email, and expected amount to your database
return res.status(200).json({
authorization_url: data.data.authorization_url,
access_code: data.data.access_code,
reference: data.data.reference,
});
}
This API route receives the customer's email and amount from your frontend, calls Paystack to initialize a transaction, and returns the checkout URL. The secret key never leaves the server.
Create a simple checkout page at pages/checkout.tsx:
// pages/checkout.tsx
import { useState } from 'react';
export default function CheckoutPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const amount = 5000; // 5,000 NGN
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) {
window.location.href = data.authorization_url;
} else {
alert(data.error || 'Payment initialization failed');
setLoading(false);
}
}
return (
<div style={{ maxWidth: 400, margin: '80px auto' }}>
<h1>Checkout</h1>
<input
type="email"
placeholder="Your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{ width: '100%', padding: 8, marginBottom: 16 }}
/>
<p>Amount: NGN {amount.toLocaleString()}</p>
<button onClick={handlePay} disabled={loading || !email}>
{loading ? 'Redirecting...' : 'Pay Now'}
</button>
</div>
);
}
Clicking "Pay Now" sends the email and amount to your API route, then redirects the user to Paystack's hosted checkout. Paystack handles card entry, 3DS, bank transfers, and all other payment channel logic.
If you prefer the customer stays on your page during payment, load Paystack Inline.js and use the access code from your API route:
// pages/checkout-popup.tsx
import { useState } from 'react';
import Script from 'next/script';
declare global {
interface Window {
PaystackPop: any;
}
}
export default function PopupCheckout() {
const [email, setEmail] = useState('');
async function handlePay() {
const res = await fetch('/api/pay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, amount: 5000 }),
});
const data = await res.json();
if (!data.access_code) {
alert(data.error || 'Failed to initialize');
return;
}
const popup = new window.PaystackPop();
popup.checkout({
accessCode: data.access_code,
onSuccess: async (transaction: { reference: string }) => {
// Verify on your server before granting value
const verifyRes = await fetch(
'/api/verify?reference=' + transaction.reference
);
const result = await verifyRes.json();
if (result.success) {
window.location.href = '/payment/success';
}
},
onCancel: () => {
console.log('Customer cancelled');
},
});
}
return (
<>
<Script src="https://js.paystack.co/v2/inline.js" />
<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 next/script component loads Paystack's JavaScript from the CDN. After successful payment in the popup, you call your verify API route before redirecting to a success page.
Create the verification API route at pages/api/verify.ts:
// pages/api/verify.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const reference = req.query.reference as string;
if (!reference) {
return res.status(400).json({ error: 'Missing reference' });
}
const paystackRes = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
const data = await paystackRes.json();
if (data.status && data.data.status === 'success') {
// TODO: Verify amount and currency match your database record
// TODO: Mark order as paid (idempotently)
return res.status(200).json({
success: true,
amount: data.data.amount / 100,
currency: data.data.currency,
reference: data.data.reference,
});
}
return res.status(400).json({ success: false, status: data.data?.status });
}
Now create the callback page using getServerSideProps to verify before rendering:
// pages/payment/callback.tsx
import type { GetServerSideProps } from 'next';
interface Props {
success: boolean;
reference: string;
amount?: number;
currency?: string;
error?: string;
}
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const reference = (context.query.reference || context.query.trxref) as string;
if (!reference) {
return { props: { success: false, reference: '', error: 'No reference' } };
}
const res = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
const data = await res.json();
if (data.status && data.data.status === 'success') {
// TODO: Mark order as paid in your database
return {
props: {
success: true,
reference,
amount: data.data.amount / 100,
currency: data.data.currency,
},
};
}
return {
props: {
success: false,
reference,
error: 'Payment status: ' + (data.data?.status || 'unknown'),
},
};
};
export default function PaymentCallback({ success, reference, amount, currency, error }: Props) {
if (success) {
return (
<div style={{ maxWidth: 500, margin: '80px auto', textAlign: 'center' }}>
<h1>Payment Successful</h1>
<p>Reference: {reference}</p>
<p>Amount: {currency} {amount?.toLocaleString()}</p>
<a href="/">Back to Home</a>
</div>
);
}
return (
<div style={{ maxWidth: 500, margin: '80px auto', textAlign: 'center' }}>
<h1>Payment Failed</h1>
<p>{error}</p>
<a href="/checkout">Try Again</a>
</div>
);
}
Using getServerSideProps means the verification runs on the server before the page HTML is sent to the browser. The customer sees either a success page or a failure page. They never see an intermediate loading state.
Before going live:
sk_test_ and pk_test_ with live keys in your production environment variables.For webhook handling in Next.js Pages Router, see Handle Paystack Webhooks in Next.js Pages Router.
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