Why Stripe Checkout and Next.js Are a Perfect Match
If you are building a modern web application with Next.js and need to accept payments, Stripe Checkout is one of the fastest and most reliable ways to do it. Stripe gives you a fully hosted, conversion-optimized payment page, and the Next.js App Router gives you powerful server-side capabilities like Server Actions and Route Handlers that make the integration clean and secure.
In this hands-on tutorial, you will learn how to integrate Stripe Checkout into a Next.js application using the App Router. We will cover everything from creating products in Stripe to handling webhooks for payment confirmation, and finally testing the full flow. By the end, you will have a working checkout system ready for production.
What You Will Need Before Starting
- Node.js 18+ installed on your machine
- A Stripe account (free to create at stripe.com)
- A Next.js 14 or 15+ project using the App Router
- Basic knowledge of React and TypeScript
- The Stripe CLI for webhook testing (optional but highly recommended)
Step 1: Create a Next.js Project
If you do not already have a Next.js project, create one using the official CLI:
npx create-next-app@latest stripe-checkout-demo --typescript --app
cd stripe-checkout-demo
Make sure you select the App Router option during setup. This tutorial relies on features specific to the App Router like Server Actions and the app/ directory structure.
Step 2: Install the Stripe Package
Install the official Stripe Node.js library:
npm install stripe
Then add your Stripe API keys to a .env.local file in the root of your project:
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx
NEXT_PUBLIC_BASE_URL=http://localhost:3000
You can find your test API keys in the Stripe Dashboard under Developers > API keys.
Step 3: Create Products in Stripe
You have two options for creating products:
Option A: Use the Stripe Dashboard
- Go to Products in your Stripe Dashboard
- Click Add Product
- Enter a name, description, and price
- Copy the Price ID (it starts with
price_)
Option B: Create Products via the API
You can also create products programmatically. Here is a quick script you could run once:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const product = await stripe.products.create({
name: 'Pro Plan',
description: 'Access to all premium features',
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00
currency: 'usd',
});
console.log('Price ID:', price.id);
For this tutorial, we will use Price IDs directly. Store them in your environment variables or a configuration file.
Step 4: Create a Stripe Utility File
Create a reusable Stripe instance to avoid initializing it in every file:
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-12-18.acacia',
typescript: true,
});
This file should only ever be imported in server-side code (Route Handlers, Server Actions, etc.). Never import your secret key on the client side.
Step 5: Build the Checkout Session with a Server Action
One of the cleanest patterns in the Next.js App Router is using Server Actions to create a Stripe Checkout session. This eliminates the need for a separate API route for session creation.
Create a server action file:
// app/actions/checkout.ts
'use server';
import { stripe } from '@/lib/stripe';
import { redirect } from 'next/navigation';
export async function createCheckoutSession(priceId: string) {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/cancel`,
});
if (session.url) {
redirect(session.url);
}
throw new Error('Failed to create checkout session');
}
This action creates a checkout session on Stripe’s servers and then redirects the user to the Stripe-hosted payment page. The {CHECKOUT_SESSION_ID} template variable is replaced automatically by Stripe.
Supporting Subscriptions
If you want to handle recurring payments instead of one-time payments, simply change the mode from 'payment' to 'subscription':
mode: 'subscription',
Step 6: Build the Product Page UI
Now create a simple product page that triggers the checkout:
// app/page.tsx
import { createCheckoutSession } from '@/app/actions/checkout';
const PRICE_ID = 'price_xxxxxxxxxxxxx'; // Replace with your actual Price ID
export default function Home() {
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
<h1>Pro Plan</h1>
<p>Get access to all premium features for $29.</p>
<form
action={async () => {
'use server';
await createCheckoutSession(PRICE_ID);
}}
>
<button
type="submit"
style={{
backgroundColor: '#635bff',
color: '#fff',
padding: '12px 24px',
border: 'none',
borderRadius: '6px',
fontSize: '16px',
cursor: 'pointer',
}}
>
Buy Now
</button>
</form>
</main>
);
}
When the user clicks “Buy Now,” the form calls the server action, which creates the session and redirects to Stripe Checkout. No client-side JavaScript is needed for this flow.
Handling a Dynamic Cart with Multiple Items
If your application has a shopping cart with multiple products, you can pass an array of line items to the checkout session:
line_items: cartItems.map((item) => ({
price: item.priceId,
quantity: item.quantity,
})),
Step 7: Create Success and Cancel Pages
Create simple pages for post-checkout redirects:
// app/success/page.tsx
export default function SuccessPage() {
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
<h1>Payment Successful!</h1>
<p>Thank you for your purchase. You will receive a confirmation email shortly.</p>
</main>
);
}
// app/cancel/page.tsx
export default function CancelPage() {
return (
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
<h1>Payment Cancelled</h1>
<p>Your payment was not processed. You can try again anytime.</p>
</main>
);
}
Important: Never rely solely on the success page to confirm a payment. A user could manually navigate to your success URL without paying. Always use webhooks (next step) to verify payment on the server side.
Step 8: Handle Webhooks for Payment Confirmation
Webhooks are essential for a production Stripe integration. Stripe sends events to your server when something happens, like a completed payment, a failed charge, or a subscription renewal.
Create a Webhook Route Handler
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe';
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json({ error: 'No signature' }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
console.log('Payment completed for session:', session.id);
// Fulfill the order here:
// - Update your database
// - Send confirmation email
// - Grant access to purchased content
break;
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.log('Payment failed:', paymentIntent.id);
break;
}
default:
console.log('Unhandled event type:', event.type);
}
return NextResponse.json({ received: true });
}
Key Webhook Events to Listen For
| Event | When It Fires | Common Use Case |
|---|---|---|
checkout.session.completed |
Customer completes payment | Fulfill order, update database |
payment_intent.succeeded |
Payment is confirmed | Send receipt or confirmation |
payment_intent.payment_failed |
Payment attempt fails | Notify the customer |
customer.subscription.created |
New subscription starts | Activate subscription access |
customer.subscription.deleted |
Subscription is cancelled | Revoke access |
Step 9: Test Your Integration
Testing is a critical part of any payment integration. Stripe provides excellent tools to test without real money.
Use Stripe Test Card Numbers
| Card Number | Scenario |
|---|---|
4242 4242 4242 4242 |
Successful payment |
4000 0000 0000 9995 |
Declined payment |
4000 0025 0000 3155 |
Requires 3D Secure authentication |
Use any future expiry date, any 3-digit CVC, and any postal code.
Test Webhooks Locally with the Stripe CLI
- Install the Stripe CLI from stripe.com/docs/stripe-cli
- Log in:
stripe login - Forward events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe - Copy the webhook signing secret the CLI outputs and paste it into your
.env.localasSTRIPE_WEBHOOK_SECRET - Make a test purchase. You should see the events logged in your terminal.
Step 10: Deploy and Go Live
When you are ready to go live, follow these steps:
- Switch to live API keys in the Stripe Dashboard (Developers > API keys, toggle off “Test mode”)
- Update environment variables in your hosting provider (Vercel, AWS, etc.) with the live keys
- Register your webhook endpoint in the Stripe Dashboard under Developers > Webhooks. Point it to
https://yourdomain.com/api/webhooks/stripe - Select the events you want to listen to (at minimum:
checkout.session.completed) - Test with a real card for a small amount, then refund it from the dashboard to verify the full flow
Complete Project Structure
Here is what your project structure should look like after completing this tutorial:
stripe-checkout-demo/
├── app/
│ ├── actions/
│ │ └── checkout.ts
│ ├── api/
│ │ └── webhooks/
│ │ └── stripe/
│ │ └── route.ts
│ ├── cancel/
│ │ └── page.tsx
│ ├── success/
│ │ └── page.tsx
│ ├── layout.tsx
│ └── page.tsx
├── lib/
│ └── stripe.ts
├── .env.local
├── package.json
└── tsconfig.json
Common Mistakes to Avoid
- Exposing your secret key on the client: Only use
STRIPE_SECRET_KEYin server-side code. The publishable key (pk_) is the only key safe for the browser. - Skipping webhook verification: Always verify the webhook signature using
stripe.webhooks.constructEvent. Without it, anyone could send fake events to your endpoint. - Trusting the success URL alone: The success redirect is for user experience only. Business logic like order fulfillment must happen inside your webhook handler.
- Not handling idempotency: Stripe may send the same webhook event more than once. Make your webhook handler idempotent by checking if you have already processed a given session ID.
- Using the Pages Router patterns: If you are on the App Router, avoid mixing in
pages/apiroutes. Use Route Handlers in theapp/directory instead.
Server Actions vs. Route Handlers: Which Should You Use?
The Next.js App Router gives you two server-side options for creating Stripe Checkout sessions. Here is a quick comparison:
| Feature | Server Actions | Route Handlers (API Routes) |
|---|---|---|
| Invocation | Called from a form or client component | Called via fetch() to an API endpoint |
| Simplicity | Very clean, fewer files | More familiar REST-style approach |
| Best for | Form submissions, simple checkout flows | Complex APIs, external integrations, webhooks |
In this tutorial we used a Server Action for creating the checkout session (simple and direct) and a Route Handler for the webhook endpoint (because Stripe sends POST requests to a URL, which requires a traditional API route).
Wrapping Up
Adding Stripe Checkout to a Next.js App Router project is straightforward once you understand the building blocks. Server Actions let you create checkout sessions without boilerplate API routes, and Route Handlers give you the flexibility to process webhooks securely.
To summarize the key steps:
- Install the Stripe package and configure your API keys
- Create your products and prices in the Stripe Dashboard
- Use a Server Action to create a Checkout Session and redirect the user
- Build success and cancel pages for post-checkout redirects
- Set up a webhook Route Handler to confirm payments on the server
- Test thoroughly with Stripe test cards and the Stripe CLI
If you need help building or scaling your Next.js application with Stripe, the team at Box Software specializes in modern web development and payment integrations. Feel free to reach out.
Frequently Asked Questions
Can I embed Stripe Checkout directly on my page instead of redirecting?
Yes. Stripe offers an Embedded Checkout option that renders the payment form directly inside your application using an iframe. You would use @stripe/react-stripe-js and the EmbeddedCheckoutProvider component. However, the redirect approach shown in this tutorial is simpler to implement and still fully customizable via the Stripe Dashboard.
How much does Stripe charge per transaction?
Stripe’s standard pricing is 2.9% + $0.30 per successful card charge in the US. International cards and currency conversions may incur additional fees. Check Stripe’s pricing page for the latest rates in your country.
Does this approach work with Next.js 15?
Yes. This tutorial is fully compatible with Next.js 14 and 15. Server Actions and Route Handlers are stable features in both versions. The code samples use the app/ directory structure which is the default in all recent Next.js versions.
How do I handle subscriptions instead of one-time payments?
Change the mode parameter in your stripe.checkout.sessions.create call from 'payment' to 'subscription'. Make sure the Price ID you use is attached to a recurring price (set up in the Stripe Dashboard when creating the product).
Do I need a database for this to work?
For a simple checkout, no. But for production applications, you should store order details, customer information, and payment status in a database. Your webhook handler is the right place to write this data after a successful payment.
Can I use this with the Next.js Pages Router?
The concepts are similar, but the implementation differs. With the Pages Router, you would create a checkout session inside a pages/api/checkout.ts file instead of using Server Actions. The webhook handler would go in pages/api/webhooks/stripe.ts. This tutorial focuses on the App Router, which is the recommended approach for new Next.js projects.
