How to Connect Stripe with Next.js?
Next.js has become one of the most popular frameworks for building modern web applications, and Stripe is one of the most widely used payment platforms. Together, they make a powerful combination: Next.js lets you run secure server-side code right next to your React components, which is exactly what a payment integration needs. Secret keys stay on the server, payment sessions are created in route handlers, and webhooks are received by the same application that serves your pages. Getting the integration right means a fast, secure checkout and orders that are always fulfilled correctly; getting it wrong can expose keys, trust manipulated prices, or miss payments entirely. In this guide, we'll walk through connecting Stripe with the Next.js App Router step by step, with code examples for every stage.
Before You Start
To follow this guide, you need a Stripe account and a Next.js project using the App Router. In the Stripe Dashboard, you'll find two types of API keys: a publishable key that can be used in the browser, and a secret key that must never leave the server. Every account also has a test mode with its own keys, so the entire integration can be built without moving real money. Start by installing the official Stripe Node.js library along with the server-only package, which prevents server code from being accidentally imported into client components.
npm install stripe server-onlyNext, add your keys to environment variables. The secret key and webhook secret must never use the NEXT_PUBLIC_ prefix, because Next.js exposes any variable with that prefix to the browser. With hosted Stripe Checkout, the publishable key isn't even needed; it only becomes necessary if you later embed Stripe Elements. Finally, create a single shared Stripe client that can only be used on the server.
# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_SITE_URL=http://localhost:3000// lib/stripe.ts
import "server-only";
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
Creating a Checkout Session
Stripe Checkout is the fastest and most secure way to accept payments. Instead of building a payment form yourself, you create a Checkout Session on the server and redirect the customer to a Stripe-hosted payment page that handles cards, digital wallets, and additional authentication such as 3D Secure. In Next.js, the natural place for this logic is a route handler.
One rule matters more than any other here: never trust the price sent from the browser. The client should only say which product or plan the customer wants, and the server should look up the actual price. Otherwise, anyone could modify the request and pay whatever amount they choose.
// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
// Prices are resolved on the server, never taken from the client
const PRICES: Record<string, string> = {
starter: "price_123",
pro: "price_456",
};
export async function POST(req: Request) {
const { plan } = await req.json();
const price = PRICES[plan];
if (!price) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
}
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
}On the client side, a small component calls this route and redirects the customer to the URL Stripe returns. This keeps the client completely free of secret logic.
// components/checkout-button.tsx
"use client";
export function CheckoutButton({ plan }: { plan: string }) {
async function handleClick() {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan }),
});
const { url } = await res.json();
window.location.href = url;
}
return <button onClick={handleClick}>Buy now</button>;
}
Handling Webhooks in the App Router
A redirect to the success page is not proof of payment. Customers can close the tab before being redirected, and some payment methods confirm later. That's why orders should be fulfilled based on webhooks, which Stripe sends to your server whenever an important event occurs, such as a completed checkout, a failed payment, or a refund.
Every webhook must be verified with its signature to ensure it genuinely comes from Stripe. Signature verification requires the raw request body, and this is where the App Router makes things simpler than the older Pages Router: calling req.text() returns the raw body directly, with no need to disable body parsing. Because Stripe may deliver the same event more than once, the fulfillment logic must also be idempotent, meaning processing an event twice should not create a second order.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { stripe } from "@/lib/stripe";
export const runtime = "nodejs";
export async function POST(req: Request) {
const body = await req.text(); // raw body is required for signature checks
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("Invalid signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillOrder(session.id); // your own logic, must be idempotent
}
return new Response(null, { status: 200 });
}
Testing and Going Live
The Stripe CLI makes local webhook testing easy. It forwards events from Stripe to your local development server and prints a temporary webhook secret to use in your environment variables. You can also trigger specific events on demand to test how your application reacts.
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completedUse Stripe's test card numbers to simulate successful payments, declined cards, and cards that require authentication. Once every scenario behaves correctly, activate your Stripe account, register your production webhook endpoint in the Dashboard, and replace the test keys with live keys in your hosting environment. Keeping test and live keys in separate environments prevents accidental real charges during development.
Security and Best Practices
The most important security rule is keeping secret keys strictly on the server: store them in environment variables, never prefix them with NEXT_PUBLIC_, and use server-only to catch mistakes at build time. For services that only need limited access, restricted API keys reduce the damage a leaked key could cause. Because card details are entered on Stripe's hosted page, sensitive payment data never touches your servers, which significantly reduces your PCI compliance scope.
For reliability, pass idempotency keys when creating important objects so that retried requests don't produce duplicate charges, store processed webhook event IDs to avoid double fulfillment, and log every payment event. Run webhook route handlers on the Node.js runtime, since the Stripe library relies on Node.js APIs. These practices turn a working checkout into a payment system that holds up under real traffic.
Conclusion
Connecting Stripe with Next.js gives you a secure, modern payment flow within a single codebase. By creating Checkout Sessions in route handlers, resolving prices on the server, verifying webhooks with the raw request body, and fulfilling orders idempotently, you build an integration that is both fast to develop and safe to run in production. From here, the same foundation extends naturally to subscriptions, customer portals, and embedded payment forms. Whether you're launching a simple product page or a full SaaS platform, getting the payment integration right is essential, and working with an experienced Next.js team that understands both Stripe and software architecture ensures your payments run on a foundation that lasts.