Stripe is the standard for online payments. This tutorial covers integrating Stripe Checkout Sessions into a Next.js App Router application for one-time payments and subscriptions.
Prerequisites
- Next.js 14+ with App Router
- A Stripe account (test mode for development)
- Node.js 18+
Step 1: Install Dependencies
pnpm add stripe @stripe/stripe-js
stripe: Server-side Stripe SDK@stripe/stripe-js: Client-side Stripe loader
Step 2: Configure Environment Variables
# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Get these from your Stripe Dashboard > Developers > API Keys.
Step 3: Create the Stripe Instance
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
typescript: true,
});
Step 4: Create a Checkout Session API Route
// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
export async function POST(request: Request) {
try {
const { priceId, mode } = await request.json();
const session = await stripe.checkout.sessions.create({
mode: mode || "payment", // "payment" for one-time, "subscription" for recurring
payment_method_types: ["card"],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Stripe checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout session" },
{ status: 500 }
);
}
}
Step 5: Create a Checkout Button Component
// components/CheckoutButton.tsx
"use client";
import { useState } from "react";
interface CheckoutButtonProps {
priceId: string;
mode?: "payment" | "subscription";
label?: string;
}
export function CheckoutButton({ priceId, mode = "payment", label = "Buy Now" }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);
async function handleCheckout() {
setLoading(true);
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId, mode }),
});
const { url } = await response.json();
if (url) {
window.location.href = url;
}
} catch (error) {
console.error("Checkout error:", error);
} finally {
setLoading(false);
}
}
return (
<button
onClick={handleCheckout}
disabled={loading}
className="rounded-md bg-blue-600 px-6 py-3 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Redirecting..." : label}
</button>
);
}
Step 6: Handle Webhooks
Webhooks notify your server when payment events occur (successful payments, failed charges, subscription changes):
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import Stripe from "stripe";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (error) {
console.error("Webhook signature verification failed:", error);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
// Fulfill the order
// - Update database
// - Send confirmation email
// - Grant access
console.log("Payment successful:", session.id);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
// Update subscription status in your database
console.log("Subscription updated:", subscription.id);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
// Revoke access
console.log("Subscription cancelled:", subscription.id);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
// Notify customer of failed payment
console.log("Payment failed:", invoice.id);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
}
Important: The webhook route must receive the raw body (not parsed JSON) for signature verification. Next.js App Router handles this correctly when you use request.text().
Step 7: Create Products and Prices in Stripe
Use the Stripe Dashboard or API:
// One-time payment product
const product = await stripe.products.create({
name: "Website Audit",
description: "Comprehensive website performance and SEO audit",
});
const price = await stripe.prices.create({
product: product.id,
unit_amount: 49900, // $499.00
currency: "usd",
});
// Subscription product
const subProduct = await stripe.products.create({
name: "Monthly Maintenance",
});
const subPrice = await stripe.prices.create({
product: subProduct.id,
unit_amount: 9900, // $99.00/month
currency: "usd",
recurring: { interval: "month" },
});
Step 8: Success Page
// app/success/page.tsx
import { stripe } from "@/lib/stripe";
interface Props {
searchParams: Promise<{ session_id?: string }>;
}
export default async function SuccessPage({ searchParams }: Props) {
const { session_id } = await searchParams;
if (!session_id) {
return <p>No session found.</p>;
}
const session = await stripe.checkout.sessions.retrieve(session_id);
return (
<main className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-3xl font-bold">Payment Successful</h1>
<p className="mt-4 text-gray-600">
Thank you for your purchase. A confirmation email has been sent to{" "}
{session.customer_details?.email}.
</p>
</div>
</main>
);
}
Step 9: Test with Stripe CLI
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger a test event
stripe trigger checkout.session.completed
Test Card Numbers
| Card Number | Result |
|---|---|
| 4242 4242 4242 4242 | Success |
| 4000 0000 0000 0002 | Declined |
| 4000 0000 0000 3220 | 3D Secure required |
Use any future expiry date and any 3-digit CVC.
Security Checklist
- Secret key never exposed to client
- Webhook signature verified
- HTTPS enforced (Vercel does this automatically)
- Idempotent webhook handling
- Amount validated on server side
- Rate limiting on checkout endpoint
Need Payment Integration?
We integrate Stripe, subscription billing, and custom checkout flows for e-commerce and SaaS applications. Contact us for payment integration services.