Use it in Next.js
app/checkout/success/page.tsx
import Link from "next/link";
import { redirect } from "next/navigation";
import { fulfillCheckout } from "@/lib/fulfill";
// Amounts come in the currency's smallest unit (cents for USD). Intl knows how many decimals each currency has.
function money(amount: number | null, currency: string | null) {
if (amount == null || !currency) return "";
const fmt = new Intl.NumberFormat("en", { style: "currency", currency });
return fmt.format(amount / 10 ** (fmt.resolvedOptions().maximumFractionDigits ?? 2));
}
export default async function CheckoutSuccess({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
if (!session_id?.startsWith("cs_")) redirect("/");
// Same function the webhook calls. Whichever runs first records the order; the other finds it done.
const { session, fulfilled } = await fulfillCheckout(session_id);
if (session.status === "open") redirect("/checkout"); // they never finished paying
if (session.status === "expired") redirect("/checkout?expired=1");
const items = session.line_items?.data ?? [];
return (
<main className="mx-auto max-w-md px-6 py-16">
<p className="text-sm text-neutral-500">Order {session.id.slice(-8)}</p>
<h1 className="mt-1 text-2xl font-semibold">
{fulfilled ? "Thanks, you're all set" : "Payment received, it's still processing"}
</h1>
{!fulfilled && (
<p className="mt-2 text-neutral-500">Your bank is confirming the payment. We'll email you when it clears.</p>
)}
<ul className="mt-8 divide-y divide-neutral-200 border-y border-neutral-200">
{items.map((li) => (
<li key={li.id} className="flex justify-between py-3">
<span>
{li.description} <span className="text-neutral-500">x {li.quantity}</span>
</span>
<span className="tabular-nums">{money(li.amount_total, li.currency)}</span>
</li>
))}
</ul>
<p className="mt-4 flex justify-between font-medium">
<span>Total</span>
<span className="tabular-nums">{money(session.amount_total, session.currency)}</span>
</p>
{session.customer_details?.email && (
<p className="mt-6 text-sm text-neutral-500">A receipt is on its way to {session.customer_details.email}.</p>
)}
<Link href="/" className="mt-8 inline-block underline">
Back to the store
</Link>
</main>
);
}
app/api/checkout/route.ts (creates the session)
import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// POST from your Buy button's form. Stripe swaps {CHECKOUT_SESSION_ID} for the real id when it redirects.
export async function POST(req: Request) {
const origin = new URL(req.url).origin;
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
success_url: `${origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/checkout?canceled=1`,
});
return NextResponse.redirect(session.url!, 303);
}
lib/fulfill.ts (safe to run twice)
import "server-only";
import Stripe from "stripe";
import { saveOrderOnce } from "@/lib/orders";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Called by the webhook AND the success page, so it must be safe to run twice for the same session.
export async function fulfillCheckout(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["line_items"] });
// "unpaid" after a completed Checkout means a delayed method (a bank debit, say) is still processing.
if (session.payment_status === "unpaid") return { session, fulfilled: false };
// saveOrderOnce writes one row per session id (a unique column) and returns false if it was already there.
await saveOrderOnce({
sessionId: session.id,
email: session.customer_details?.email ?? null,
items: (session.line_items?.data ?? []).map((li) => ({ name: li.description ?? "", quantity: li.quantity ?? 1 })),
});
return { session, fulfilled: true };
}
app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { fulfillCheckout } from "@/lib/fulfill";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // the raw body: the signature is checked against these exact bytes
const signature = req.headers.get("stripe-signature") ?? "";
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Bad signature", { status: 400 });
}
if (event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded") {
await fulfillCheckout(event.data.object.id);
}
return new Response(null, { status: 200 });
}
Props
- order
- the order the receipt shows (items, total)
- renderCardFields
- slot for Stripe's card Elements; card digits never reach the component
- onConfirm
- confirm the payment with Stripe, return ok and last4
- onComplete
- gets the receipt: orderId, total, last4, paidAt
- autoplayDemo
- false in a real app
- accent / secondary
- brand colours