
How to Add Stripe Payments to a Next.js Application?
Taking payments is the point where a side project becomes a business, and it is also the point where small mistakes get expensive. A price that comes from the browser can be edited. A success page that unlocks an order can be visited without paying. A webhook that skips signature verification can be called by anyone. Stripe solves the hard parts of payments, but the integration code you write in Next.js decides whether those guarantees actually hold.
This article covers a complete Stripe integration for the Next.js App Router: setting up the Stripe SDK on the server, creating Checkout Sessions from a Server Action, building success and cancel pages, verifying webhooks in a route handler, fulfilling orders idempotently, testing everything locally with the Stripe CLI, and extending the setup to subscriptions and the customer portal. All examples use TypeScript and Next.js 16.
How a Stripe Checkout Integration Works
Stripe offers several ways to collect payments, from fully custom card forms built with Stripe Elements to hosted pages where Stripe owns the entire checkout UI. For most Next.js apps, Stripe Checkout is the right starting point: Stripe hosts the payment page, handles cards, wallets, local payment methods, 3D Secure, tax, and receipts, and your app only needs to create a session and react to the result.
The flow has five steps:
- The user clicks a buy button in your app.
- Your server creates a Checkout Session with the Stripe API, using prices defined on the server.
- The browser is redirected to the session's hosted URL on
checkout.stripe.com. - After payment, Stripe redirects the user back to your
success_url. - Stripe sends a webhook event to your server, which is where you actually fulfill the order.
The most important rule is in step 5. The redirect to the success page is a convenience for the user, not proof of payment. Users close tabs, lose connections, and some payment methods settle hours or days later. The webhook is the only reliable signal, so fulfillment logic belongs there.
| Piece | Runs where | Responsibility |
|---|---|---|
| Stripe client | Server only | Holds the secret key, calls the Stripe API |
| Server Action | Server | Creates the Checkout Session and redirects |
| Buy button | Client or server form | Submits the form that triggers the action |
| Success page | Server Component | Shows a confirmation using the session ID |
| Webhook route handler | Server | Verifies the signature and fulfills the order |
Setting Up Stripe in a Next.js Project
Install the official Node.js SDK:
# Terminal
npm install stripe
Then add your keys to .env.local. You find the secret key in the Stripe Dashboard under Developers, API keys. Always start in test mode, where keys begin with sk_test_.
# .env.local
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Neither Stripe secret has a NEXT_PUBLIC_ prefix, which keeps them out of the browser bundle. With hosted Checkout you do not need the publishable key at all, because the browser never talks to Stripe directly. If you are new to how Next.js separates server and public variables, see how to use environment variables in Next.js.
Create a single Stripe client and mark the module as server-only so it can never be imported into a Client Component by accident:
# Terminal
npm install server-only
// lib/stripe.ts
import "server-only";
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
typescript: true,
});
The SDK uses the API version it was built against by default. For production, pin the version explicitly with the apiVersion option, using the value shown in your Stripe Dashboard and in the SDK's type definitions, so an SDK upgrade never changes API behavior without you noticing.
Defining Products and Prices
Create your products in the Stripe Dashboard (or with the API) and copy each price ID, which looks like price_1Q.... Keep a server-side map of what your app sells. This is the key protection against price tampering: the client sends a product identifier, and the server decides what it costs.
// lib/products.ts
export const products = {
"starter-course": {
name: "Starter Course",
priceId: "price_1QxxxxStarter",
mode: "payment",
},
"pro-plan": {
name: "Pro Plan",
priceId: "price_1QxxxxProMonthly",
mode: "subscription",
},
} as const;
export type ProductKey = keyof typeof products;
export function isProductKey(value: string): value is ProductKey {
return value in products;
}
In a larger app this list lives in your database, but the principle is the same: never accept an amount or price ID from form data without checking it against a trusted source.
Creating a Checkout Session with a Server Action
Server Actions are a natural fit for starting checkout. The form posts directly to a server function, which creates the session and redirects, with no API route and no client-side fetch. For a deeper look at the pattern, see how to use Server Actions to handle mutations in Next.js.
// app/actions/checkout.ts
"use server";
import { redirect } from "next/navigation";
import { stripe } from "@/lib/stripe";
import { products, isProductKey } from "@/lib/products";
export async function createCheckoutSession(formData: FormData) {
const productKey = String(formData.get("product") ?? "");
if (!isProductKey(productKey)) {
throw new Error("Unknown product");
}
const product = products[productKey];
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const session = await stripe.checkout.sessions.create({
mode: product.mode,
line_items: [{ price: product.priceId, quantity: 1 }],
success_url: `${siteUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${siteUrl}/pricing?canceled=1`,
metadata: { productKey },
allow_promotion_codes: true,
});
if (!session.url) {
throw new Error("Stripe did not return a Checkout URL");
}
redirect(session.url);
}
A few details worth noticing:
{CHECKOUT_SESSION_ID}is a literal placeholder. Stripe replaces it with the real session ID when it redirects back, so the success page can look the session up.metadatacarries your own identifiers through to the webhook. Add a user ID or order ID here so fulfillment knows who paid for what.redirect()must be called outside anytry/catch, because it works by throwing a special error that Next.js handles. Wrapping it intry/catchswallows the redirect.- The site URL comes from configuration, not from the incoming request's
Hostheader, which avoids building redirect URLs from untrusted input.
If the user is signed in, pass their email with customer_email, or better, create a Stripe Customer once, store its ID on your user record, and pass customer so every purchase is attached to the same customer.
The Buy Button
Because the action accepts FormData, the button can be a plain form in a Server Component. It works even before JavaScript loads:
// app/pricing/page.tsx
import { createCheckoutSession } from "@/app/actions/checkout";
import { products } from "@/lib/products";
export default async function PricingPage({
searchParams,
}: {
searchParams: Promise<{ canceled?: string }>;
}) {
const { canceled } = await searchParams;
return (
<main className="mx-auto max-w-3xl p-8">
<h1 className="text-3xl font-bold">Pricing</h1>
{canceled && (
<p className="mt-4 rounded bg-yellow-100 p-3">
Checkout was canceled. You have not been charged.
</p>
)}
<div className="mt-8 grid gap-6 sm:grid-cols-2">
{Object.entries(products).map(([key, product]) => (
<form key={key} action={createCheckoutSession} className="rounded border p-6">
<h2 className="text-xl font-semibold">{product.name}</h2>
<input type="hidden" name="product" value={key} />
<button type="submit" className="mt-4 rounded bg-black px-4 py-2 text-white">
Buy {product.name}
</button>
</form>
))}
</div>
</main>
);
}
In Next.js 16, searchParams is a Promise in pages, so it is awaited before use.
Showing a Pending State
Creating a session takes a moment. A small Client Component with useFormStatus disables the button while the request is in flight and prevents double submissions:
// app/pricing/checkout-button.tsx
"use client";
import { useFormStatus } from "react-dom";
export function CheckoutButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="mt-4 rounded bg-black px-4 py-2 text-white disabled:opacity-50"
>
{pending ? "Redirecting to checkout..." : label}
</button>
);
}
Replace the plain button in the pricing page with CheckoutButton and the form keeps working as before.
Building the Success Page
The success page receives the session ID in the query string. Retrieve the session on the server to show the customer what they bought, but do not grant access here. Treat this page as a receipt, not a trigger.
// app/checkout/success/page.tsx
import { redirect } from "next/navigation";
import { stripe } from "@/lib/stripe";
export default async function SuccessPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
if (!session_id) {
redirect("/pricing");
}
const session = await stripe.checkout.sessions.retrieve(session_id, {
expand: ["line_items"],
});
const item = session.line_items?.data[0];
const isPaid = session.payment_status === "paid" || session.payment_status === "no_payment_required";
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="text-3xl font-bold">
{isPaid ? "Thank you for your purchase" : "Your payment is processing"}
</h1>
<p className="mt-4">
{item?.description} for{" "}
{((session.amount_total ?? 0) / 100).toFixed(2)} {session.currency?.toUpperCase()}
</p>
<p className="mt-2 text-gray-600">
A receipt has been sent to {session.customer_details?.email}.
</p>
</main>
);
}
Amounts from Stripe are in the smallest currency unit, so 2900 means 29.00 in USD or EUR. Zero-decimal currencies such as JPY are not divided by 100, so use a formatting helper if you sell in several currencies.
The payment_status check matters for delayed payment methods like bank debits. Those sessions complete with payment_status: "unpaid" and settle later, which is another reason fulfillment happens in the webhook.
Handling Webhooks in a Route Handler
Webhooks are HTTP POST requests from Stripe to your server. In the App Router, they belong in a route handler. If route handlers are new to you, what are API routes in Next.js covers the background.
Signature verification needs the raw request body, byte for byte as Stripe sent it. Parsing it as JSON first and re-serializing it changes the bytes and breaks the signature. In a route handler, read it with request.text():
// app/api/webhooks/stripe/route.ts
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { fulfillCheckout } from "@/lib/fulfillment";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!signature || !secret) {
return new Response("Missing signature or secret", { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, secret);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return new Response(`Webhook signature verification failed: ${message}`, {
status: 400,
});
}
try {
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded": {
const session = event.data.object;
await fulfillCheckout(session.id);
break;
}
case "checkout.session.async_payment_failed": {
const session = event.data.object;
console.warn("Async payment failed for session", session.id);
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object;
console.log("Subscription changed", subscription.id, subscription.status);
break;
}
default:
// Ignore events you did not subscribe to handle
break;
}
} catch (error) {
console.error("Webhook handler failed", error);
return new Response("Webhook handler failed", { status: 500 });
}
return Response.json({ received: true });
}
How this handler behaves:
- A bad signature returns 400. Stripe will not retry a request your server rejected as invalid, and an attacker gets nothing useful back.
- A processing failure returns 500. Stripe retries failed deliveries with exponential backoff for up to three days in live mode, so a temporary database outage does not lose an order.
- Unknown events still return 200. Returning errors for events you simply do not handle causes pointless retries.
event.data.objectis typed by the event type in the switch, sosessionis aStripe.Checkout.Sessionwithout a manual cast.
Route handlers that use the Request object are dynamic by default, so this route is never cached or prerendered. If your project has a proxy.ts (the file that replaced middleware.ts in Next.js 16) that enforces authentication, exclude /api/webhooks from its matcher, because Stripe cannot log in.
Fulfilling Orders Idempotently
Stripe delivers webhooks at least once. The same event can arrive twice, and checkout.session.completed and checkout.session.async_payment_succeeded can both fire for one purchase. Your fulfillment function has to be safe to call repeatedly for the same session.
// lib/fulfillment.ts
import "server-only";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
export async function fulfillCheckout(sessionId: string) {
// Always re-fetch from Stripe instead of trusting the event payload alone
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items"],
});
if (session.payment_status === "unpaid") {
// Delayed payment method; wait for async_payment_succeeded
return;
}
const existing = await db.order.findUnique({
where: { stripeSessionId: session.id },
});
if (existing?.fulfilledAt) {
return; // Already fulfilled, nothing to do
}
await db.order.upsert({
where: { stripeSessionId: session.id },
create: {
stripeSessionId: session.id,
productKey: session.metadata?.productKey ?? "unknown",
email: session.customer_details?.email ?? "",
amountTotal: session.amount_total ?? 0,
currency: session.currency ?? "usd",
fulfilledAt: new Date(),
},
update: { fulfilledAt: new Date() },
});
// Grant access, send a license key, enqueue shipping, etc.
}
The db object here is a Prisma client, with a unique constraint on stripeSessionId so two concurrent deliveries cannot create two orders. Any database works as long as it enforces that uniqueness. For a full setup, see how to use Prisma ORM with Next.js.
Re-fetching the session from Stripe instead of relying only on the event payload has two benefits: you always act on the latest state, and you can expand related objects such as line_items, which webhook payloads do not include.
Testing Locally with the Stripe CLI
Stripe cannot reach localhost, so use the Stripe CLI to forward events to your development server:
# Terminal
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe listen prints a webhook signing secret starting with whsec_. Put that value in STRIPE_WEBHOOK_SECRET in .env.local and restart npm run dev. The CLI secret is different from the one for a Dashboard endpoint, which is a common source of signature failures.
With the listener running, go through checkout with Stripe's test cards:
| Card number | Result |
|---|---|
| 4242 4242 4242 4242 | Successful payment |
| 4000 0025 0000 3155 | Requires 3D Secure authentication |
| 4000 0000 0000 9995 | Declined for insufficient funds |
Use any future expiry date, any three-digit CVC, and any postal code. You can also fire events without going through the UI:
# Terminal
stripe trigger checkout.session.completed
Triggered events use fixture data, so their metadata will not match your products. They are useful for checking that signature verification and routing work, while a real test-mode checkout verifies the full flow.
Adding Subscriptions and the Customer Portal
Subscriptions use the same Checkout flow with mode: "subscription" and a recurring price. The pro-plan entry in the products map already does this, so the Server Action handles it without changes. What changes is what you track afterwards: subscription status lives on the Stripe Subscription object and changes over time, so listen for customer.subscription.updated and customer.subscription.deleted and keep your database in sync.
For plan changes, payment method updates, invoices, and cancellations, use Stripe's hosted customer portal rather than building those screens yourself. Enable it in the Dashboard under Settings, Billing, Customer portal, then add an action that creates a portal session:
// app/actions/billing.ts
"use server";
import { redirect } from "next/navigation";
import { stripe } from "@/lib/stripe";
import { getCurrentUser } from "@/lib/auth";
export async function openBillingPortal() {
const user = await getCurrentUser();
if (!user?.stripeCustomerId) {
redirect("/pricing");
}
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/account`,
});
redirect(portal.url);
}
// app/account/page.tsx
import { openBillingPortal } from "@/app/actions/billing";
export default function AccountPage() {
return (
<form action={openBillingPortal}>
<button type="submit" className="rounded border px-4 py-2">
Manage billing
</button>
</form>
);
}
getCurrentUser stands in for your authentication layer. The important part is that the customer ID comes from your session on the server, never from form data, so a user can only open the portal for their own account.
Going Live
Before switching to live keys:
- Create a live webhook endpoint in the Dashboard pointing to
https://yourdomain.com/api/webhooks/stripe, subscribed only to the events you handle. Copy its signing secret into your productionSTRIPE_WEBHOOK_SECRET. - Swap to live keys in your hosting provider's environment settings, not in committed files.
- Recreate products and prices in live mode, since test and live data are separate, and update your price IDs. Many teams keep separate price IDs per environment in environment variables.
- Set
NEXT_PUBLIC_SITE_URLto the production domain, and rebuild, becauseNEXT_PUBLIC_values are inlined at build time. - Check the webhook delivery log in the Dashboard after your first real purchase to confirm 200 responses.
Common Problems and Fixes
- "No signatures found matching the expected signature for payload." The body was modified before verification, or the wrong secret is in use. Read the body with
request.text(), do not callrequest.json()first, and make sure the secret matches the endpoint or the runningstripe listensession. - The redirect to Checkout never happens.
redirect()was called insidetry/catch, which caught the redirect signal. Move it after the block. - Orders are fulfilled twice. The handler is not idempotent. Add a unique constraint on the session ID and check for an existing fulfilled order before acting.
- Webhooks return 401 or 307 in production. Authentication logic in
proxy.tsor a trailing-slash redirect is intercepting the request. Exclude the webhook path from the proxy matcher and register the exact URL in Stripe. - Success page shows "processing" forever. The customer used a delayed payment method. Fulfill on
checkout.session.async_payment_succeeded, and email the customer when it completes rather than relying on the page. - Prices are wrong in production. The live environment is still using test price IDs. Move price IDs into environment variables or the database per environment.
Stripe and Next.js FAQ
Either works. A Server Action is simpler for forms inside your Next.js app because it needs no fetch call and works before JavaScript loads. Use a route handler when something outside your app, such as a mobile client, needs to create sessions over HTTP.
You should not rely on it. Users can close the browser before the redirect, delayed payment methods are not complete at that point, and anyone can visit the success URL. Use the page for confirmation and do fulfillment in the webhook handler.
Almost always because the body was not the raw text Stripe sent, or because the signing secret is wrong. Read the body with request.text in the route handler, pass it unchanged to constructEvent, and use the secret printed by stripe listen locally or the endpoint secret from the Dashboard in production.
No. With hosted Checkout the server creates the session and the browser is redirected to Stripe, so no client-side Stripe code runs. You need the publishable key only for Stripe Elements or Embedded Checkout, which load Stripe.js in the browser.
The Stripe SDK supports non-Node runtimes, but webhook verification on the Edge needs the asynchronous verification method with a Web Crypto provider. The default Node.js runtime for route handlers is the simplest and best-supported choice for payments.
Stripe Tax can calculate tax automatically when enabled on the Checkout Session, and prices can define amounts for several currencies. Both are configured in Stripe rather than in your Next.js code, which keeps the integration small.
Conclusion
A solid Stripe integration in Next.js is mostly about putting each responsibility in the right place. The server decides prices and creates the Checkout Session in a Server Action. Stripe hosts the payment page and handles authentication, wallets, and compliance. The success page confirms the purchase to the user, and the webhook route handler verifies the signature against the raw body and fulfills the order idempotently.
Start in test mode, run stripe listen while you develop, test the failure cases as carefully as the happy path, and keep secrets on the server. With that structure in place, adding subscriptions, the customer portal, coupons, or tax is a matter of configuration rather than a rewrite.
Here are some useful references for going deeper on Stripe with Next.js:
- Stripe Docs: Stripe Checkout quickstart — the official walkthrough for creating Checkout Sessions.
- Stripe Docs: Fulfill orders — Stripe's guidance on webhook-based, idempotent fulfillment.
- Stripe Docs: Receive Stripe events in your webhook endpoint — signature verification, retries, and best practices.
- Stripe Docs: Stripe CLI — forwarding and triggering webhook events locally.
- Next.js Docs: Route Handlers — how route handlers receive requests in the App Router.


