Accept Payments with Paystack in Next.js Pages Router
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.
Prerequisites
Before you start, you need:
- A Paystack account with API keys (Settings > API Keys & Webhooks)
- Node.js 16 or later
- A Next.js project using the Pages Router (the
pages/directory)
If your project uses the App Router (app/ directory), see Accept Payments with Paystack in Next.js App Router instead.
Project Setup and Environment Variables
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.
Initialize a Transaction with an API Route
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.
Build the Checkout Page
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.
Inline Popup Checkout
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.
Verify Payment on the Callback 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.
Production Checklist
Before going live:
- Switch to live keys. Replace
sk_test_andpk_test_with live keys in your production environment variables. - Set up webhooks. Add your webhook URL in the Paystack dashboard. Webhooks handle cases where the redirect fails (customer closes browser, network drops).
- Verify amount and currency. Always compare the verified amount and currency against your database record. Do not just check status === 'success'.
- Handle double-delivery. Both webhook and callback can trigger fulfillment. Use a database flag to prevent granting value twice.
- Use HTTPS. Paystack requires HTTPS for callback and webhook URLs in production.
For webhook handling in Next.js Pages Router, see Handle Paystack Webhooks in Next.js Pages Router.
Key Takeaways
- ✓API routes in pages/api/ run server-side and are the right place for Paystack secret key operations. Never import your secret key in a page component.
- ✓Use getServerSideProps on the callback page to verify the transaction before the page renders. This keeps the verification logic on the server.
- ✓Amounts must be in the smallest currency unit. For NGN, multiply the Naira amount by 100 to get kobo.
- ✓The redirect callback URL receives ?reference=REF as a query parameter. Extract it and verify before granting any value.
- ✓Set up webhooks alongside the redirect callback. Webhooks fire even if the customer closes their browser mid-payment.
- ✓Test keys (sk_test_) and live keys (sk_live_) use separate transaction pools. A reference created with test keys cannot be verified with live keys.
Frequently Asked Questions
- Should I use the Pages Router or App Router for a new Paystack integration?
- If you are starting a new project, the App Router is the recommended approach from Next.js. It offers Server Actions, better streaming, and a simpler mental model for server-side operations. If you have an existing Pages Router project, there is no need to migrate just for Paystack. Both routers work fine with the Paystack API.
- Can I call the Paystack API directly from getServerSideProps?
- Yes. getServerSideProps runs on the server, so it is safe to use your Paystack secret key there. This is useful for the callback page where you want to verify the payment before the page renders.
- Why do I get a CORS error when calling Paystack from the frontend?
- You cannot call the Paystack API directly from the browser. The API requires your secret key in the Authorization header, and exposing that key in the browser is a security vulnerability. Always make Paystack API calls from your API routes (server-side) and have your frontend call your API routes instead.
- How do I test Paystack payments locally?
- Use your test keys (sk_test_ and pk_test_). Paystack provides test card numbers in their documentation. For testing webhooks locally, use ngrok or Cloudflare Tunnel to expose your localhost to the internet, then set that URL as your webhook URL in the Paystack dashboard.
- What happens if my server crashes between payment and verification?
- This is exactly why webhooks exist. Even if your callback page never loads, the webhook will fire with the payment confirmation. Set up a webhook handler that independently verifies and fulfills orders. When both webhook and callback are working, whichever fires first handles fulfillment, and the other one is a no-op.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon