Paystack Popup in Next.js with @paystack/inline-js
app/pay/paystack-button.tsx
"use client";
import { useState } from "react";
import { startPayment, verifyPayment } from "./actions";
type State = "idle" | "opening" | "paid" | "cancelled" | "failed";
export default function PaystackButton({ email }: { email: string }) {
const [state, setState] = useState<State>("idle");
async function pay() {
setState("opening");
try {
// @paystack/inline-js reads window as soon as it loads, so import it here, on click.
const { default: PaystackPop } = await import("@paystack/inline-js");
// The server creates the transaction with your secret key and sends back an access code.
const { accessCode, reference } = await startPayment(email);
new PaystackPop().resumeTransaction(accessCode, {
onSuccess: async () => setState((await verifyPayment(reference)) ? "paid" : "failed"),
onCancel: () => setState("cancelled"),
onError: () => setState("failed"),
});
} catch {
setState("failed");
}
}
return (
<div className="space-y-3">
<button
type="button"
onClick={pay}
disabled={state === "opening" || state === "paid"}
className="rounded-xl bg-emerald-500 px-5 py-3 font-semibold text-black disabled:opacity-60"
>
{state === "opening" ? "Opening Paystack..." : "Pay with Paystack"}
</button>
<p role="status" className="text-sm text-neutral-400">
{state === "paid" && "Paid and verified on the server."}
{state === "cancelled" && "You closed the payment window. Nothing was charged."}
{state === "failed" && "That payment did not go through."}
</p>
</div>
);
}
app/pay/actions.ts
"use server";
import { paystack, type Initialized, type Verified } from "@/lib/paystack";
// Set on the server, never by the browser. Paystack counts in the lowest unit: kobo for NGN.
const AMOUNT = 500000; // kobo
const CURRENCY = "NGN";
export async function startPayment(email: string) {
const tx = await paystack<Initialized>("/transaction/initialize", {
email,
amount: String(AMOUNT),
currency: CURRENCY,
});
return { accessCode: tx.access_code, reference: tx.reference };
}
export async function verifyPayment(reference: string) {
const tx = await paystack<Verified>(`/transaction/verify/${encodeURIComponent(reference)}`);
// The popup saying success is not proof. Check the status, the amount and the currency here.
return tx.status === "success" && tx.amount === AMOUNT && tx.currency === CURRENCY;
}
lib/paystack.ts
// Server only. The secret key never reaches the browser.
const API = "https://api.paystack.co";
type Envelope<T> = { status: boolean; message: string; data: T };
export type Initialized = { authorization_url: string; access_code: string; reference: string };
export type Verified = { status: string; reference: string; amount: number; currency: string };
export async function paystack<T>(path: string, body?: Record<string, unknown>): Promise<T> {
const res = await fetch(API + path, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
cache: "no-store",
});
const json = (await res.json()) as Envelope<T>;
// A wrong key, a bad amount or an unknown reference comes back with status false and a message.
if (!res.ok || !json.status) throw new Error(json.message || `Paystack answered ${res.status}`);
return json.data;
}
app/pay/page.tsx
import PaystackButton from "./paystack-button";
export default function PayPage() {
// Use the signed in customer's email. Paystack sends the receipt there.
return (
<main className="mx-auto max-w-md p-6">
<PaystackButton email="[email protected]" />
</main>
);
}