Accept Payments with Paystack in Next.js App Router
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.
Prerequisites
Before you start, make sure you have:
- A Paystack account with test keys ready (Settings > API Keys & Webhooks)
- Node.js 18 or later installed
- A Next.js 14+ project using the App Router (the
app/directory, notpages/)
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.
Project Setup and Environment Variables
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.
Initialize a Transaction with a Route Handler
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:
- The
Authorizationheader 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.- The
callback_urltells Paystack where to redirect the customer after payment. We will build this page next. - Save the reference to your database before returning. Your webhook handler and callback handler both need to look up the order by reference.
Build the Checkout Page
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.
Inline Popup Checkout with a Server Action
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.
Verify the Payment Server-Side
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.
Handle the Redirect Callback
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.
Security Checklist and Going Live
Before switching from test keys to live keys, run through this checklist:
- Secret key is server-only. Search your codebase for your secret key. It must never appear in a Client Component, a
'use client'file, or any file that gets bundled for the browser. Route Handlers and Server Actions are safe. Server Components are safe. - Verify every transaction. Never grant value based on the redirect alone. Always call Paystack's verify endpoint and check status, amount, and currency.
- Prevent double-granting. Both the webhook and the redirect callback can arrive for the same transaction. Use a database flag (like
order.paid = true) and check it before fulfilling. Whichever fires first sets the flag; the second one skips. - Set up webhooks. In the Paystack dashboard, add your webhook URL. Webhooks are the reliable delivery mechanism. Redirects can fail if the customer closes their browser. See Handle Paystack Webhooks in Next.js App Router.
- Use HTTPS in production. Paystack requires HTTPS for callback URLs and webhook URLs. Next.js on Vercel provides this by default.
- Switch keys. Replace
sk_test_andpk_test_withsk_live_andpk_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.
Key Takeaways
- ✓Route Handlers in app/api/ replace the old Pages Router API routes. They use the Web Request/Response API, which maps cleanly to Paystack API calls.
- ✓Your Paystack secret key must stay on the server. Never import it in a Client Component. Route Handlers and Server Actions both run server-side by default.
- ✓Use Server Actions for inline popup checkout where the user stays on the page, and Route Handlers for redirect-based checkout where the user leaves your site.
- ✓Always verify the transaction server-side after the redirect callback. The redirect itself is not proof of payment.
- ✓Amounts are in kobo (NGN), pesewas (GHS), or cents (ZAR/KES/USD). Multiply the display price by 100 before sending to Paystack.
- ✓Store the transaction reference in your database before redirecting. This lets your webhook handler and callback handler both find the correct order.
Frequently Asked Questions
- Can I use Paystack with Next.js middleware?
- Next.js middleware runs at the edge and is meant for request routing, not API calls. You should initialize and verify Paystack transactions in Route Handlers or Server Actions, not in middleware. Middleware cannot reliably make outbound API calls to Paystack because of edge runtime limitations.
- Should I use a Route Handler or a Server Action for Paystack?
- Use a Route Handler (app/api/ route) when you want a standard REST endpoint that works with redirect-based checkout, webhooks, or external services calling your server. Use a Server Action when you want inline popup checkout where the client component calls the server function directly. Both keep your secret key on the server.
- How do I handle Paystack webhooks in Next.js App Router?
- Create a Route Handler at app/api/webhooks/paystack/route.ts. Parse the raw body, verify the x-paystack-signature header using HMAC SHA-512 with your secret key, and process the event. Return a 200 response immediately before doing any heavy processing. See our dedicated webhook guide for the full implementation.
- Why does my Paystack test payment fail with "Transaction not found"?
- This usually means you are verifying with a live secret key but initialized with a test key, or vice versa. Make sure PAYSTACK_SECRET_KEY in your .env.local matches the mode you used to initialize. Test keys start with sk_test_ and live keys start with sk_live_.
- Do I need to install the paystack npm package?
- No. The Paystack API is a standard REST API. You can call it with the built-in fetch function in Next.js. Community packages like paystack-node exist but are not required and add an unnecessary dependency for most integrations. The only client-side script you might need is Paystack Inline.js loaded from the CDN for popup checkout.
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