TutorialMarch 20, 2026

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.

12 min readNext.js 14/15 + App RouterUpdated March 2026

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@latest if 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.

1

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.

terminalbash
npm install stripe

Next, add your Stripe secret key and your app's base URL to .env.local:

.env.localenv
STRIPE_SECRET_KEY=sk_test_your_key_here
NEXT_PUBLIC_BASE_URL=http://localhost:3000

You'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.

2

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:

app/api/checkout/route.tstypescript
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.

3

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:

app/components/CheckoutButton.tsxtsx
"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:

app/page.tsx (example usage)tsx
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.

4

Add a success page

After a successful payment, Stripe redirects the customer to your success_url. Let's create that page:

app/success/page.tsxtsx
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">&#10003;</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"
        >
          &larr; 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.

5

Test with Stripe test cards

Start your dev server and try a test payment:

terminalbash
npm run dev

Click your “Buy Now” button. On Stripe's checkout page, use these test credentials:

Card number4242 4242 4242 4242ExpiryAny future date (e.g. 12/34)CVCAny 3 digits (e.g. 123)Name / ZipAnything you want

After 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 authentication
4000 0000 0000 9995Simulates a declined card
4000 0000 0000 0077Always succeeds (even with insufficient funds)

Go live: production checklist

Before you deploy to production, run through this checklist:

Swap sk_test_ for sk_live_ in your production environment variables
Update NEXT_PUBLIC_BASE_URL to your production domain (no trailing slash)
Verify .env.local is in .gitignore and not committed
Complete Stripe onboarding (bank account connected, business details filled in)
Test a real $1 transaction to confirm end-to-end flow works
Set up webhook endpoints for fulfillment (covered in our next lesson)
Enable Stripe Radar for fraud protection (on by default for new accounts)

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:

project structuretext
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.json

That'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