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-jsAjoutez 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.deletedLe 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
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 →