Tous les articles
Next.jsBackendStripe

Stripe + Next.js 16: Checkout Sessions, Webhook Handlers, and Subscription Sync

//
·9 min de lecture

A complete Stripe integration for Next.js: Checkout Sessions in Server Actions, webhook Route Handlers with signature verification, subscription lifecycle management, and real-time sync to PostgreSQL.

Integrating Stripe with Next.js 15/16 is cleaner than ever: Server Actions handle checkout creation without a separate API route, Route Handlers process webhooks with signature verification, and the App Router makes it straightforward to protect pages behind a subscription check. This guide covers the full subscription lifecycle.

>Setup

bash
npm install stripe @stripe/stripe-js
npm install --save-dev stripe-cli   # optional local testing
TypeScript
// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-03-31.basil',
  typescript: true,
});

// Client-side only
export const getStripe = () =>
  import('@stripe/stripe-js').then(({ loadStripe }) =>
    loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!),
  );

>Create a Checkout Session — Server Action

TypeScript
// app/actions/checkout.ts
'use server';
import { redirect } from 'next/navigation';
import { stripe } from '@/lib/stripe';
import { getServerSession } from '@/lib/auth';

export async function createCheckoutSession(priceId: string) {
  const session = await getServerSession();
  if (!session) throw new Error('Unauthenticated');

  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer_email: session.user.email,
    client_reference_id: session.user.id,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=1`,
    cancel_url:  `${process.env.NEXT_PUBLIC_URL}/pricing`,
    subscription_data: {
      metadata: { userId: session.user.id },
    },
  });

  redirect(checkoutSession.url!);
}

// Client component — call as a form action
'use client';
import { createCheckoutSession } from '@/app/actions/checkout';

export function UpgradeButton({ priceId }: { priceId: string }) {
  return (
    <form action={createCheckoutSession.bind(null, priceId)}>
      <button type="submit">Upgrade to Pro</button>
    </form>
  );
}

>Webhook Route Handler

TypeScript
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';

export async function POST(req: NextRequest) {
  const body      = await req.text();
  const signature = req.headers.get('stripe-signature')!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  switch (event.type) {

    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      await db.subscription.create({
        data: {
          userId:           session.client_reference_id!,
          stripeCustomerId: session.customer as string,
          stripeSubId:      session.subscription as string,
          status:           'active',
          plan:             'pro',
        },
      });
      break;
    }

    case 'customer.subscription.updated': {
      const sub = event.data.object as Stripe.Subscription;
      await db.subscription.update({
        where: { stripeSubId: sub.id },
        data:  { status: sub.status, currentPeriodEnd: new Date(sub.current_period_end * 1000) },
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription;
      await db.subscription.update({
        where: { stripeSubId: sub.id },
        data:  { status: 'canceled' },
      });
      break;
    }
  }

  return NextResponse.json({ received: true });
}

NOTEDisable body parsing for the webhook route — Next.js sends the raw body by default in Route Handlers (no extra config needed). Always verify the signature before processing.

>Customer Portal — manage subscription

TypeScript
// app/actions/portal.ts
'use server';
import { redirect } from 'next/navigation';
import { stripe } from '@/lib/stripe';
import { getServerSession } from '@/lib/auth';
import { db } from '@/lib/db';

export async function openCustomerPortal() {
  const session = await getServerSession();
  const sub = await db.subscription.findUnique({
    where: { userId: session!.user.id },
  });

  const portalSession = await stripe.billingPortal.sessions.create({
    customer:   sub!.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard`,
  });

  redirect(portalSession.url);
}

>Protect pages with subscription check

TypeScript
// middleware.ts → proxy.ts (Next.js 16)
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from '@/lib/auth';
import { db } from '@/lib/db';

export default async function proxy(req: NextRequest) {
  if (!req.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.next();
  }

  const session = await getServerSession();
  if (!session) return NextResponse.redirect(new URL('/login', req.url));

  const sub = await db.subscription.findFirst({
    where: { userId: session.user.id, status: 'active' },
  });

  if (!sub) return NextResponse.redirect(new URL('/pricing', req.url));
  return NextResponse.next();
}

export const config = { matcher: ['/dashboard/:path*'] };