Building a Stripe Payment Integration in Next.js: The Complete Guide
A production-ready guide to integrating Stripe Checkout, webhooks, and subscription billing into a Next.js App Router application.
What We're Building
A complete Stripe integration covering:
- One-time payments via Stripe Checkout
- Subscription billing with customer portal
- Webhook handling to sync payment state
- Protecting pages behind an active subscription check
Setup
npm install stripe @stripe/stripe-js
Store keys in environment variables. Never expose the secret key to the client.
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
Creating a Checkout Session
Create a Route Handler that initialises a Stripe Checkout session and redirects the user.
// app/api/checkout/route.ts
import Stripe from 'stripe'
import { NextResponse } from 'next/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const { priceId, customerId } = await request.json()
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/pricing`,
})
return NextResponse.json({ url: session.url })
}
On the client, redirect to the Stripe-hosted checkout page:
// components/UpgradeButton.tsx
'use client'
async function handleCheckout(priceId: string) {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
window.location.href = url
}
Handling Webhooks
Webhooks are how Stripe notifies your server about payment events. This is the most critical part — your database state must stay in sync with Stripe.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { headers } from 'next/headers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.text()
const signature = (await headers()).get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
return new Response('Webhook signature verification failed', { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.CheckoutSession
await activateSubscription(session.customer as string)
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
await deactivateSubscription(sub.customer as string)
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
await handlePaymentFailure(invoice.customer as string)
break
}
}
return new Response(null, { status: 200 })
}
Critical: Always return a 200 response promptly. Stripe retries failed webhooks — if your handler takes too long or throws, Stripe will retry and you may process events twice. Use idempotency keys in your database operations.
Testing Webhooks Locally
Use the Stripe CLI to forward webhook events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This gives you a local STRIPE_WEBHOOK_SECRET to use in development.
Protecting Routes by Subscription Status
Use Next.js Middleware or a server-side check to gate dashboard access:
// lib/subscription.ts
import { db } from './db'
export async function getSubscriptionStatus(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: { subscriptionStatus: true, subscriptionExpiresAt: true },
})
return user?.subscriptionStatus === 'active'
}
// app/dashboard/page.tsx
import { getSubscriptionStatus } from '@/lib/subscription'
import { getCurrentUser } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const user = await getCurrentUser()
const isActive = await getSubscriptionStatus(user.id)
if (!isActive) redirect('/pricing')
return <Dashboard />
}
Customer Portal
Let subscribers manage their own billing without you building a billing UI:
// app/api/billing-portal/route.ts
export async function POST(request: Request) {
const { customerId } = await request.json()
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_BASE_URL}/dashboard`,
})
return NextResponse.json({ url: session.url })
}
Common Mistakes
1. Trusting the client on payment confirmation. Never rely on ?success=true in the URL to activate a subscription. Only trust webhook events.
2. Not verifying webhook signatures. Without stripe.webhooks.constructEvent, any server can send fake payment events to your endpoint.
3. Blocking the webhook handler. Keep webhook handlers fast. Enqueue heavy work (sending emails, provisioning resources) to a background job.
4. Missing the payment_intent.succeeded vs checkout.session.completed distinction. For subscriptions, listen to checkout.session.completed and customer.subscription.* events, not payment intent events.
A production Stripe integration isn't complicated, but it requires discipline — especially around webhook reliability and idempotency.
Continue reading
Related articles
Rate Limiting in Next.js: Protecting Your API Routes
How to implement production-grade rate limiting in Next.js — with Middleware-level protection, per-user limits, and distributed rate limiting using Upstash Redis.
EngineeringNext.js Parallel Routes and Intercepting Routes: A Complete Guide
Parallel routes and intercepting routes are among the most powerful App Router primitives. This guide explains what they do, when to use them, and how to avoid the common pitfalls.
EngineeringVercel vs Netlify vs AWS Amplify for Next.js in 2026
A practical comparison of the three most common Next.js hosting platforms — Vercel, Netlify, and AWS Amplify — with real cost and capability trade-offs.
Stay informed
Get our monthly deep dives.
Engineering, design, and growth insights — once a month. No spam.
Browse all resources