How to Add Stripe Checkout to Next.js
in 15 Minutes (2026 Guide)
The fastest way to accept real payments in your Next.js app. Full working code you can copy-paste, common gotchas that trip everyone up, and a production checklist so you ship with confidence.
What we'll build
- Why Stripe Checkout (not a custom form)
- Prerequisites
- Install and configure the Stripe SDK
- Create the Checkout API route
- Build the “Buy Now” button component
- Add a success page
- Test with Stripe test cards
- Go live: production checklist
- Common gotchas and how to fix them
- Next steps: webhooks, subscriptions, and more
Why Stripe Checkout (and not a custom payment form)?
If you're adding payments to a Next.js app for the first time, you have two main options: build a custom payment form with Stripe Elements, or use Stripe Checkout — Stripe's hosted payment page.
For 90% of projects, Checkout is the right choice to start with. Here's why:
Zero PCI hassle
Stripe hosts the payment page. Card numbers never touch your server. You skip PCI compliance entirely.
Built-in UX
Apple Pay, Google Pay, Link, address collection, tax calculation, error handling — all included for free.
Ships in minutes
About 30 lines of code total. No client-side form state, no validation logic, no payment element styling.
You can always migrate to Stripe Elements later when you need full UI control. But for shipping fast and validating your idea? Checkout is the move.
Prerequisites
Before we start, make sure you have:
- ✓A Stripe account (free) — sign up at dashboard.stripe.com
- ✓Your test-mode API keys — find them under Developers → API keys in the Stripe Dashboard
- ✓A Next.js project using the App Router — run
npx create-next-app@latestif you need a fresh one - ✓Node.js 18+ installed on your machine
Want the quick-reference version? Grab our free 5-Minute Stripe Integration Checklist — a step-by-step checklist you can follow while you code. No email required.
Install and configure the Stripe SDK
You only need one package: stripe. This is the server-side Node.js SDK. You don't need @stripe/stripe-js or any client-side library for Checkout.
npm install stripeNext, add your Stripe secret key and your app's base URL to .env.local:
STRIPE_SECRET_KEY=sk_test_your_key_here
NEXT_PUBLIC_BASE_URL=http://localhost:3000You'll find your test-mode secret key in the Stripe Dashboard under Developers → API keys. It starts with sk_test_.
Never commit your Stripe secret key to Git. .env.local is already in .gitignore by default in Next.js — but double-check yours to be safe.
Create the Checkout API route
This is the core of the integration. When a user clicks your “Buy” button, the frontend calls this API route. It creates a Stripe Checkout Session and returns the URL to redirect the customer to Stripe's hosted payment page.
Create the file app/api/checkout/route.ts:
import { NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: "Your Product Name",
description: "A short description of what the customer is buying",
},
unit_amount: 2999, // $29.99 in cents
},
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/`,
});
return NextResponse.json({ url: session.url });
} catch (err) {
console.error("Stripe checkout error:", err);
return NextResponse.json(
{ error: "Failed to create checkout session" },
{ status: 500 }
);
}
}Let's break down the important parts:
mode: "payment"One-time payment. Use "subscription" for recurring billing (we cover that in Lesson 3).price_dataCreates pricing inline. You can also create a Price in the Stripe Dashboard and use price: "price_xxx" instead.unit_amount: 2999Price in cents. $29.99 = 2999. This trips up everyone at least once.success_urlWhere Stripe redirects after payment. {CHECKOUT_SESSION_ID} is a Stripe template variable that gets replaced automatically.cancel_urlWhere the customer goes if they click "back" on the Stripe page.unit_amount is in cents, not dollars. Writing 2999 charges $29.99. Writing 29.99 charges $0.30. This is the #1 Stripe integration mistake.
Build the “Buy Now” button component
This is a client component that calls your API route and redirects to Stripe. Create app/components/CheckoutButton.tsx:
"use client";
import { useState } from "react";
export default function CheckoutButton() {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const data = await res.json();
if (data.url) {
window.location.href = data.url;
} else {
console.error("No checkout URL returned");
setLoading(false);
}
} catch (err) {
console.error("Checkout error:", err);
setLoading(false);
}
};
return (
<button
onClick={handleCheckout}
disabled={loading}
className="px-6 py-3 bg-indigo-600 text-white font-semibold
rounded-lg hover:bg-indigo-500 disabled:opacity-50
disabled:cursor-not-allowed transition-colors"
>
{loading ? "Redirecting..." : "Buy Now — $29.99"}
</button>
);
}Now you can import and use this button anywhere in your app:
import CheckoutButton from "./components/CheckoutButton";
export default function Home() {
return (
<main className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-6">
<h1 className="text-4xl font-bold">My Awesome Product</h1>
<p className="text-gray-600">One-time purchase, instant access.</p>
<CheckoutButton />
</div>
</main>
);
}When clicked, the flow is: button click → POST /api/checkout → Stripe creates a session → user is redirected to Stripe's hosted page → customer pays → redirected back to your success page.
Use window.location.href for the redirect, not Next.js router.push(). The Checkout URL is on Stripe's domain — it's not a route in your app.
Add a success page
After a successful payment, Stripe redirects the customer to your success_url. Let's create that page:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export default async function SuccessPage({
searchParams,
}: {
searchParams: Promise<{ session_id?: string }>;
}) {
const { session_id } = await searchParams;
let customerEmail = null;
if (session_id) {
try {
const session = await stripe.checkout.sessions.retrieve(session_id);
customerEmail = session.customer_details?.email;
} catch {
// Invalid session ID — just show generic message
}
}
return (
<div className="min-h-screen flex items-center justify-center p-6">
<div className="text-center space-y-4 max-w-md">
<div className="text-5xl mb-2">✓</div>
<h1 className="text-3xl font-bold">Payment successful!</h1>
<p className="text-gray-600">
{customerEmail
? `A receipt has been sent to ${customerEmail}.`
: "Check your email for the receipt."}
</p>
<a
href="/"
className="inline-block mt-4 text-indigo-600 hover:underline"
>
← Back to home
</a>
</div>
</div>
);
}Notice we're retrieving the Checkout Session using the session_id from the URL. This lets us personalize the page — showing the customer's email, for example. This is a server component, so the API call happens on your server, not in the browser.
Test with Stripe test cards
Start your dev server and try a test payment:
npm run devClick your “Buy Now” button. On Stripe's checkout page, use these test credentials:
4242 4242 4242 4242ExpiryAny future date (e.g. 12/34)CVCAny 3 digits (e.g. 123)Name / ZipAnything you wantAfter completing the test payment, you should land on your success page. Check the Stripe Dashboard → Payments tab to see the test transaction.
Stripe also provides special test card numbers for edge cases:
4000 0000 0000 3220Triggers 3D Secure authentication4000 0000 0000 9995Simulates a declined card4000 0000 0000 0077Always succeeds (even with insufficient funds)Go live: production checklist
Before you deploy to production, run through this checklist:
Common gotchas (and how to fix them)
"No such price" error
If you're using a price ID instead of price_data, make sure the ID matches your environment. Test-mode price IDs only work with test-mode API keys. You can't mix sk_test_ with a live price ID.
Redirect doesn't work in development
Your NEXT_PUBLIC_BASE_URL must match your dev server exactly: http://localhost:3000 (no trailing slash, correct port). If you're using a custom port, update it.
"Failed to fetch" or CORS errors
This usually means your API route has a syntax error and is returning Next.js's HTML error page instead of JSON. Check your terminal for the real error message.
unit_amount confusion
Stripe uses cents for all amounts. $10 = 1000, $29.99 = 2999, $0.50 = 50. If your price looks wrong, this is almost always why.
Works in test mode, fails in production
Three things to check: (1) you're using sk_live_ not sk_test_, (2) your Stripe account has completed onboarding, (3) your NEXT_PUBLIC_BASE_URL points to your production domain.
Customer pays but nothing happens
The success page redirect is purely cosmetic — it doesn't confirm payment. For real fulfillment (sending emails, unlocking content, updating databases), you need webhooks. That's Lesson 2 in our series.
Complete code reference
Here's every file in one place. Your project structure should look like this:
your-nextjs-app/
├── app/
│ ├── api/
│ │ └── checkout/
│ │ └── route.ts ← Creates Stripe Checkout sessions
│ ├── components/
│ │ └── CheckoutButton.tsx ← Client-side "Buy" button
│ ├── success/
│ │ └── page.tsx ← Post-payment confirmation page
│ ├── layout.tsx
│ └── page.tsx ← Your homepage (import CheckoutButton here)
├── .env.local ← STRIPE_SECRET_KEY + NEXT_PUBLIC_BASE_URL
└── package.jsonThat's three files and about 80 lines of code for a complete, production-ready payment flow. You now have a Next.js app that can accept real money.
Next steps: what you still need
Stripe Checkout handles the payment — but a real product needs more. Here's what typically comes next:
Webhooks
Confirm payments server-side, trigger fulfillment, handle failed charges. Don't rely on the success page redirect alone.
Subscriptions
Recurring billing with Stripe Billing. Manage plan changes, cancellations, and dunning for failed payments.
Customer portal
Let customers manage their own billing: update cards, view invoices, cancel plans. Stripe has a hosted portal for this.
Failed payment recovery
Smart retry logic, dunning emails, and grace periods so you don't lose revenue to expired cards.
Keep learning
This is Lesson 1 of our full Stripe integration course library
Get the complete series — webhooks, subscriptions, failed payments, customer portals, and more — at founding member pricing: $14.50/mo for life.
50% off founding member pricing · Locked in for life · Cancel anytime