NeuraAPI
Guide 20 juin 2026 14 min de lecture

Configurer Stripe Billing dans Next.js 14

Partager :

Stripe Billing est la solution standard pour monétiser un SaaS. Il gère les abonnements, les factures, les renouvellements automatiques et les changements de plan. Ce guide détaille l'intégration complète dans Next.js 14, du premier checkout au portail client.

Prérequis

  • Un projet Next.js 14 avec App Router
  • Un compte Stripe (mode test)
  • Stripe CLI installé (pour les webhooks en local)
  • Auth configurée (NextAuth ou autre)

Étape 1 — Configurer Stripe dans votre projet

Installez le SDK Stripe et configurez les variables d'environnement :

npm install stripe @stripe/stripe-js

Ajoutez dans votre fichier .env.local :

STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
NEXT_PUBLIC_APP_URL=http://localhost:3000

Étape 2 — Créer les produits et prix dans Stripe

Avant de coder, configurez vos plans dans le dashboard Stripe. Pour chaque plan, créez un Product puis un Price en mode récurrent :

  • Starter — 19€/mois, 1 000 crédits
  • Pro — 49€/mois, 5 000 crédits
  • Entreprise — 99€/mois, 20 000 crédits

Notez les price_id de chaque prix. Vous en aurez besoin pour le checkout.

Étape 3 — Créer la route de checkout

La route de checkout redirige l'utilisateur vers la page de paiement Stripe. Elle crée une session de checkout avec le bon prix et les métadonnées :

// app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export const PLANS = {
  starter: {
    name: 'Starter',
    priceId: 'price_starter_monthly',
    credits: 1000,
  },
  pro: {
    name: 'Pro',
    priceId: 'price_pro_monthly',
    credits: 5000,
  },
  enterprise: {
    name: 'Entreprise',
    priceId: 'price_enterprise_monthly',
    credits: 20000,
  },
} as const

export async function POST(req: NextRequest) {
  const session = await auth()
  if (!session?.user) {
    return NextResponse.json(
      { error: 'Non autorisé' },
      { status: 401 }
    )
  }

  const { plan } = await req.json()
  const selectedPlan = PLANS[plan as keyof typeof PLANS]

  if (!selectedPlan) {
    return NextResponse.json(
      { error: 'Plan invalide' },
      { status: 400 }
    )
  }

  const checkoutSession = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    customer_email: session.user.email!,
    line_items: [
      {
        price: selectedPlan.priceId,
        quantity: 1,
      },
    ],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
    metadata: {
      userId: session.user.id,
      plan: plan,
    },
  })

  return NextResponse.json({ url: checkoutSession.url })
}

Le metadata est crucial : il sera transmis au webhook pour associer l'abonnement à l'utilisateur dans votre base de données.

Étape 4 — Configurer les webhooks Stripe

Les webhooks sont le mécanisme par lequel Stripe vous notifie des événements (paiement réussi, abonnement annulé, carte expirée...). C'est la partie la plus critique de l'intégration :

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

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

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

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return NextResponse.json(
      { error: 'Signature invalide' },
      { status: 400 }
    )
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      const userId = session.metadata?.userId
      const plan = session.metadata?.plan

      if (userId && plan) {
        await prisma.user.update({
          where: { id: userId },
          data: {
            plan,
            stripeCustomerId: session.customer as string,
          },
        })

        await prisma.subscription.create({
          data: {
            userId,
            stripeSubscriptionId: session.subscription as string,
            status: 'active',
            plan,
            currentPeriodEnd: new Date(
              (session.subscription as any).current_period_end * 1000
            ),
          },
        })
      }
      break
    }

    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription
      const status = subscription.status

      await prisma.subscription.updateMany({
        where: {
          stripeSubscriptionId: subscription.id,
        },
        data: {
          status,
          currentPeriodEnd: new Date(
            subscription.current_period_end * 1000
          ),
        },
      })
      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription

      await prisma.subscription.updateMany({
        where: {
          stripeSubscriptionId: subscription.id,
        },
        data: { status: 'canceled' },
      })

      // Downgrade l'utilisateur au plan gratuit
      const sub = await prisma.subscription.findUnique({
        where: { stripeSubscriptionId: subscription.id },
      })
      if (sub) {
        await prisma.user.update({
          where: { id: sub.userId },
          data: { plan: 'free' },
        })
      }
      break
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice
      // Envoyer un email d'alerte à l'utilisateur
      console.error('Paiement échoué pour:', invoice.customer)
      break
    }
  }

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

Ce webhook gère les 4 événements principaux : checkout réussi, mise à jour d'abonnement, annulation, et échec de paiement. Chaque événement met à jour votre base de données en conséquence.

Étape 5 — Tester les webhooks en local

Stripe CLI vous permet de tester les webhooks sans les déployer en production :

# Installer Stripe CLI
npm i -g stripe

# Se connecter à votre compte Stripe
stripe login

# Forwarder les webhooks vers votre serveur local
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Dans un autre terminal, déclencher des événements de test
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted

Le CLI affiche un webhook signing secret que vous utilisez en tant que STRIPE_WEBHOOK_SECRET en local.

Étape 6 — Créer le portail client Stripe

Le portail client Stripe permet à vos utilisateurs de gérer leur abonnement (changer de plan, mettre à jour la carte, annuler) sans que vous ayez à coder une seule page :

// app/api/stripe/portal/route.ts
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST() {
  const session = await auth()
  if (!session?.user) {
    return NextResponse.json(
      { error: 'Non autorisé' },
      { status: 401 }
    )
  }

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

  return NextResponse.json({ url: portalSession.url })
}

Stripe gère toute l'UI du portail : formulaire de carte, historique de factures, options de downgrade/upgrade. Vous n'avez rien à maintenir.

Étape 7 — Afficher le statut d'abonnement

Dans votre dashboard, affichez le plan actuel de l'utilisateur et un bouton pour gérer l'abonnement :

// components/SubscriptionStatus.tsx
'use client'

import { useState } from 'react'

interface Props {
  plan: string
  status: string
  currentPeriodEnd: string
}

export function SubscriptionStatus({ plan, status, currentPeriodEnd }: Props) {
  const [loading, setLoading] = useState(false)

  const handleManage = async () => {
    setLoading(true)
    const res = await fetch('/api/stripe/portal', { method: 'POST' })
    const data = await res.json()
    if (data.url) {
      window.location.href = data.url
    }
    setLoading(false)
  }

  return (
    <div className="rounded-xl border border-indigo-800/50 bg-indigo-900/30 p-6">
      <h3 className="text-lg font-semibold text-white">Abonnement</h3>
      <div className="mt-4 space-y-2">
        <p className="text-indigo-200">
          Plan : <span className="font-semibold text-white capitalize">{plan}</span>
        </p>
        <p className="text-indigo-200">
          Statut : <span className={status === 'active' ? 'text-green-400' : 'text-red-400'}>{status === 'active' ? 'Actif' : 'Inactif'}</span>
        </p>
        <p className="text-sm text-indigo-400">
          Renouvellement le {new Date(currentPeriodEnd).toLocaleDateString('fr-FR')}
        </p>
      </div>
      <button
        onClick={handleManage}
        disabled={loading}
        className="mt-6 rounded-lg border border-indigo-500 px-4 py-2 text-sm font-semibold text-indigo-200 hover:bg-indigo-900/50 transition-all disabled:opacity-50"
      >
        {loading ? 'Chargement...' : 'Gérer l'abonnement'}
      </button>
    </div>
  )
}

Bonnes pratiques

Toujours valider la signature des webhooks

Ne faites jamais confiance au contenu brut d'une requête webhook. Stripe fournit une signature que vous devez vérifier avec constructEvent() pour vous assurer que la requête vient bien de Stripe.

Traiter les webhooks de manière idempotente

Stripe peut renvoyer un même webhook plusieurs fois. Votre handler doit pouvoir être exécuté plusieurs fois sans effets de bord. Utilisez updateMany ou vérifiez l'état avant de modifier.

Utiliser le mode test avant de passer en production

Testez chaque scénario en mode test : checkout réussi, annulation, échec de paiement, downgrade. Stripe fournit des cartes de test spécifiques pour chaque cas.

Envoyer des emails transactionnels

Configurez les emails Stripe (factures, rappels de paiement) dans le dashboard. Cela réduit le support client et améliore le taux de rétention.

Récapitulatif

1Étape 1Installer le SDK Stripe et configurer les variables
2Étape 2Créer les produits et prix dans le dashboard Stripe
3Étape 3Route API checkout pour créer les sessions de paiement
4Étape 4Route webhook pour synchroniser les statuts
5Étape 5Tester les webhooks en local avec Stripe CLI
6Étape 6Portail client pour l'autogestion des abonnements
7Étape 7Afficher le statut dans le dashboard utilisateur

Besoin d'un raccourci ?

Le template NeuraSaaS inclut toute l'intégration Stripe Billing pré-configurée : checkout, webhooks, portail client, gestion des plans. Gagnez 20h de développement.

Voir les tarifs →