All insights
Engineering10 min read

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

code
npm install stripe @stripe/stripe-js

Store keys in environment variables. Never expose the secret key to the client.

code
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.

code
// 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:

code
// 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.

code
// 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:

code
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:

code
// 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'
}
code
// 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:

code
// 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.

Stay informed

Get our monthly deep dives.

Engineering, design, and growth insights — once a month. No spam.

Browse all resources