Réponse rapide : Pour un SaaS Next.js 15, le pattern recommandé est Stripe Checkout (page hébergée) + webhooks idempotents (la source de vérité) + Customer Portal (gestion abonnement par l'user) + Stripe Tax (TVA EU automatique). Setup complet : 4 à 8 jours.
En résumé (TL;DR)
- Stripe Checkout par défaut (3DS, Apple/Google Pay, intl, Stripe Tax inclus).
- Webhooks idempotents : event.id en table avec UNIQUE constraint, retourner 200 si déjà vu.
- Customer Portal pour upgrades/downgrades/annulations sans coder vos formulaires.
- Stripe Tax obligatoire si B2C multi-pays UE (0,5 % du volume taxé).
- Stripe CLI pour tester webhooks en local — gain énorme.
- Source de vérité = webhook, jamais le client.
1. Setup compte Stripe France
- Créer le compte sur
dashboard.stripe.com, choisir France comme pays. - Renseigner l'entité business : raison sociale, SIRET, adresse, RIB pour les payouts.
- Activer la TVA : Settings → Tax → activer Stripe Tax (renseigner votre n° TVA FR).
- Configurer les Invoice settings : mentions légales obligatoires (taux pénalité retard, conditions remboursement, mentions auto-entrepreneur si applicable).
- Récupérer les clés API : Secret key (sk_test_… puis sk_live_…) et Publishable key.
2. Créer les Products et Prices dans Stripe
Dans le dashboard, créer 1 Product par plan (ex: "Pro Plan") et 2 Prices par Product (mensuel + annuel) :
| Product | Price ID | Récurrence | Montant |
|---|---|---|---|
| Starter | price_starter_monthly | Mensuel | 9 € HT |
| Starter | price_starter_yearly | Annuel | 90 € HT (–17 %) |
| Pro | price_pro_monthly | Mensuel | 29 € HT |
| Pro | price_pro_yearly | Annuel | 290 € HT |
Stocker les Price IDs dans .env.local ou en DB :
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_PRICE_STARTER_MONTHLY=price_1...
STRIPE_PRICE_STARTER_YEARLY=price_1...
STRIPE_PRICE_PRO_MONTHLY=price_1...
STRIPE_PRICE_PRO_YEARLY=price_1...
3. Schéma DB minimal
Schéma Prisma pour stocker l'état Stripe en local (la DB est votre read replica, le webhook la met à jour). Source de vérité = Stripe, mais nous dupliquons pour les queries rapides.
model User {
id String @id @default(cuid())
email String @unique
stripeCustomerId String? @unique
subscription Subscription?
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
stripeSubscriptionId String @unique
stripeCustomerId String
stripePriceId String
status String // active, trialing, past_due, canceled
currentPeriodEnd DateTime
cancelAtPeriodEnd Boolean @default(false)
}
model StripeProcessedEvent {
id String @id // = stripe event.id
type String
createdAt DateTime @default(now())
}
La table StripeProcessedEvent est la clé de l'idempotence webhook.
4. Lib Stripe partagée
// lib/stripe.ts
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) throw new Error('STRIPE_SECRET_KEY manquant');
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
export const PRICES = {
starter_monthly: process.env.STRIPE_PRICE_STARTER_MONTHLY!,
starter_yearly: process.env.STRIPE_PRICE_STARTER_YEARLY!,
pro_monthly: process.env.STRIPE_PRICE_PRO_MONTHLY!,
pro_yearly: process.env.STRIPE_PRICE_PRO_YEARLY!,
} as const;
export type PriceKey = keyof typeof PRICES;
5. Route /api/stripe/checkout
// app/api/stripe/checkout/route.ts
import { NextResponse } from 'next/server';
import { stripe, PRICES, PriceKey } from '@/lib/stripe';
import { auth } from '@/auth';
import { db } from '@/lib/db';
export async function POST(req: Request) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const { priceKey } = (await req.json()) as { priceKey: PriceKey };
if (!PRICES[priceKey]) return NextResponse.json({ error: 'invalid_price' }, { status: 400 });
const user = await db.user.findUnique({ where: { id: session.user.id } });
if (!user) return NextResponse.json({ error: 'no_user' }, { status: 404 });
// Créer ou réutiliser un Customer Stripe
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
});
customerId = customer.id;
await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: customerId } });
}
const checkout = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: PRICES[priceKey], quantity: 1 }],
subscription_data: {
trial_period_days: 14,
metadata: { userId: user.id },
},
success_url: `${process.env.APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.APP_URL}/pricing?checkout=cancelled`,
automatic_tax: { enabled: true },
customer_update: { address: 'auto', name: 'auto' },
tax_id_collection: { enabled: true }, // pour la TVA B2B EU
allow_promotion_codes: true,
billing_address_collection: 'required',
locale: 'fr',
});
return NextResponse.json({ url: checkout.url });
}
Points clés :
customerréutilisé si l'user a déjà unstripeCustomerId→ évite les doublons côté Stripe.automatic_tax: enabledactive Stripe Tax (TVA EU calculée auto).tax_id_collectionpermet aux clients B2B de saisir leur n° TVA pour le reverse charge.trial_period_days: 14donne 14 jours d'essai sans CC immédiate.locale: 'fr'traduit la page Checkout en français.
6. Composant Pricing client
// components/Pricing.tsx
'use client';
import { useState } from 'react';
const plans = [
{ name: 'Starter', price: '9 €/mois', priceKey: 'starter_monthly', features: ['1 user', '100 leads/mois', 'Email support'] },
{ name: 'Pro', price: '29 €/mois', priceKey: 'pro_monthly', features: ['5 users', '1000 leads/mois', 'Support prioritaire', 'API access'], featured: true },
];
export default function Pricing() {
const [loading, setLoading] = useState<string | null>(null);
async function handleCheckout(priceKey: string) {
setLoading(priceKey);
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceKey }),
});
const { url, error } = await res.json();
if (error) { alert(error); setLoading(null); return; }
window.location.href = url;
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{plans.map(p => (
<div key={p.name} className={`p-8 rounded-xl border ${p.featured ? 'bg-cacao text-creme' : 'bg-white'}`}>
<h3 className="text-2xl font-display">{p.name}</h3>
<p className="text-3xl font-display mt-2">{p.price}</p>
<ul className="my-6 space-y-2">
{p.features.map(f => <li key={f}>✓ {f}</li>)}
</ul>
<button
onClick={() => handleCheckout(p.priceKey)}
disabled={loading === p.priceKey}
className="w-full py-3 bg-cacao text-creme rounded-lg disabled:opacity-50"
>
{loading === p.priceKey ? 'Redirection…' : 'Démarrer · 14 j gratuits'}
</button>
</div>
))}
</div>
);
}
7. Webhook idempotent — la clé de la robustesse
// app/api/stripe/webhook/route.ts
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
export const config = { api: { bodyParser: false } };
export async function POST(req: Request) {
const body = await req.text();
const sig = (await headers()).get('stripe-signature');
if (!sig) return NextResponse.json({ error: 'no_signature' }, { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return NextResponse.json({ error: 'invalid_signature' }, { status: 400 });
}
// === Idempotence ===
// Si event.id déjà traité, retourner 200 sans rien faire.
try {
await db.stripeProcessedEvent.create({
data: { id: event.id, type: event.type },
});
} catch (e: any) {
if (e.code === 'P2002') {
// unique constraint failed = déjà traité
return NextResponse.json({ ok: true, duplicate: true });
}
throw e;
}
// === Dispatch ===
try {
switch (event.type) {
case 'checkout.session.completed': {
const s = event.data.object as Stripe.Checkout.Session;
if (s.mode !== 'subscription') break;
const subId = s.subscription as string;
const sub = await stripe.subscriptions.retrieve(subId);
await upsertSubscription(sub);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
await upsertSubscription(sub);
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await db.subscription.update({
where: { stripeSubscriptionId: sub.id },
data: { status: 'canceled' },
});
break;
}
case 'invoice.payment_failed': {
const inv = event.data.object as Stripe.Invoice;
// logique dunning : envoyer un email à l'user, log
console.log('Payment failed for invoice', inv.id);
break;
}
// autres events ignorés
}
} catch (err) {
console.error('Webhook handler error', err);
// Important : on rollback la ligne stripe_processed_events si on crash
await db.stripeProcessedEvent.delete({ where: { id: event.id } }).catch(() => {});
return NextResponse.json({ error: 'handler_failed' }, { status: 500 });
}
return NextResponse.json({ ok: true });
}
async function upsertSubscription(sub: Stripe.Subscription) {
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
const user = await db.user.findUnique({ where: { stripeCustomerId: customerId } });
if (!user) return;
await db.subscription.upsert({
where: { stripeSubscriptionId: sub.id },
create: {
userId: user.id,
stripeSubscriptionId: sub.id,
stripeCustomerId: customerId,
stripePriceId: sub.items.data[0].price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
update: {
stripePriceId: sub.items.data[0].price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
});
}
Pourquoi c'est idempotent : la première chose que nous faisons est db.stripeProcessedEvent.create({ id: event.id }). Si Stripe renvoie le même event 2 fois, le second call échoue sur la contrainte UNIQUE → nous retournons 200 sans rien faire.
8. Customer Portal pour la gestion de l'abonnement
// app/api/stripe/portal/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { auth } from '@/auth';
import { db } from '@/lib/db';
export async function POST() {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
const user = await db.user.findUnique({ where: { id: session.user.id } });
if (!user?.stripeCustomerId) return NextResponse.json({ error: 'no_customer' }, { status: 400 });
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/dashboard`,
locale: 'fr',
});
return NextResponse.json({ url: portal.url });
}
Avant d'utiliser le Portal, configurer une fois dans Stripe Dashboard → Settings → Billing → Customer portal : activer "Customers can switch plans", "Customers can cancel subscriptions", choisir les Products éligibles, etc.
Côté UI :
// app/dashboard/billing/page.tsx
'use client';
export default function BillingPage() {
async function openPortal() {
const r = await fetch('/api/stripe/portal', { method: 'POST' });
const { url } = await r.json();
window.location.href = url;
}
return (
<div>
<h1>Facturation</h1>
<button onClick={openPortal} className="px-6 py-3 bg-cacao text-creme rounded-lg">
Gérer mon abonnement →
</button>
</div>
);
}
Le Portal permet à l'user : changer de plan (avec proration auto), changer de mode de paiement, télécharger ses factures, annuler. Vous gagnez 2-3 jours de dev sur des écrans peu différenciants.
9. Tester avec Stripe CLI
# Installer la CLI
brew install stripe/stripe-cli/stripe # macOS
# ou télécharger sur stripe.com/docs/stripe-cli
# Login
stripe login
# Forwarder les webhooks vers votre dev local
stripe listen --forward-to localhost:3000/api/stripe/webhook
# → Affiche un webhook secret whsec_xxx, à mettre dans .env.local
# Simuler un événement spécifique
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
Cartes de test à utiliser dans Checkout :
4242 4242 4242 4242— paiement OK4000 0027 6000 3184— déclenche 3D Secure4000 0000 0000 9995— declined "insufficient funds"
10. Bascule en production — checklist
- Activer le compte (KYC validé, RIB renseigné, identité confirmée)
- Recréer les Products / Prices côté live (les test ne migrent pas)
- Mettre à jour
.env.productionavec les cléssk_live_…et nouveauxprice_… - Configurer le webhook endpoint en live (Stripe Dashboard → Developers → Webhooks → Add endpoint, sélectionner les 6 events critiques)
- Récupérer le webhook secret live et l'ajouter à
STRIPE_WEBHOOK_SECRET - Tester un paiement en mode live avec une vraie carte (en remboursement immédiat possible)
- Configurer Customer Portal en live (les settings test ne migrent pas)
- Activer Stripe Tax en mode live
- Vérifier les Invoice settings (mentions légales FR)
- Mettre en place le monitoring : alertes Slack / email sur webhook errors via Stripe Dashboard
11. Erreurs typiques à éviter
- Confier la confirmation de paiement au front-end (success_url côté client). Le webhook est la SEULE source de vérité — un attaquant peut forger une success_url.
- Stocker price_id en dur dans le code. Toujours via env var ou DB pour pouvoir les changer sans redéploiement.
- Ne pas vérifier la signature webhook. Sans
stripe.webhooks.constructEvent(), n'importe qui peut envoyer du faux à votre endpoint. - Mélanger clés test et prod dans le même
.env. Toujours un fichier par environnement. - Oublier l'idempotence. Stripe retente les webhooks plusieurs fois.
- Ne pas logger les events. Loggez TOUT — sans logs, un bug en prod est ingérable.
- Coder un Customer Portal custom. Le Portal Stripe est gratuit, traduit, conforme. Utilisez-le.
La règle 2026 : Stripe Checkout + webhooks idempotents + Customer Portal. C'est le pattern le plus simple, le plus robuste, le plus rapide à mettre en place. Vous ajoutez Stripe Tax si B2C multi-pays UE. Tout le reste (UI custom Elements, abonnements complexes hors Portal) ne se justifie qu'à grande échelle.
Questions fréquentes
Combien de temps pour intégrer Stripe dans un Next.js ?
Setup minimal (Checkout + 1 plan + webhook basique) : 1 journée pour un dev expérimenté Next.js. Setup complet production-ready (3 plans, essais, Customer Portal, webhooks idempotents, Stripe Tax, facturation FR, tests) : 4 à 8 jours.
Stripe Checkout ou Stripe Elements (custom UI) ?
Stripe Checkout par défaut en 2026 : 3DS géré, Apple/Google Pay activés en 1 case, traduit, Stripe Tax inclus. Vous gagnez 3-5 jours de dev. Stripe Elements seulement si la page de checkout fait partie de votre proposition de valeur. Pour un SaaS B2B/B2C : Checkout, sans hésitation.
Pourquoi les webhooks doivent être idempotents ?
Stripe peut retenter un webhook plusieurs fois (timeout, restart). Sans idempotence, vous obtenez des doublons en DB. Solution : table stripe_processed_events avec UNIQUE constraint sur event.id. Si déjà vu : retourner 200 sans rien faire.
Stripe Tax est-il vraiment utile en France ?
Oui, presque obligatoire si vous vendez en B2C dans plusieurs pays UE. Stripe Tax calcule la TVA selon le pays du client (OSS 2021), gère la VAT VIES B2B (reverse charge), produit des rapports OSS. Tarif : 0,5 % du volume taxé.
Comment offrir un essai gratuit sur Stripe ?
Trois patterns. Trial sans CC (UX friendly, conversion plus basse), trial avec CC via trial_period_days (UX friction, conversion plus haute, le plus utilisé en B2B), freemium (compte gratuit ad-vitam + plan payant).
Quels événements webhook Stripe traiter en priorité ?
6 events critiques : checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed. Ne pas traiter tous les ~150 events disponibles.
Comment tester les webhooks en local ?
Avec la Stripe CLI : stripe listen --forward-to localhost:3000/api/stripe/webhook. Tunnel sécurisé, signe les events avec un secret de test. Permet aussi de simuler des events spécifiques avec stripe trigger.
Comment gérer les changements de plan (upgrade/downgrade) ?
Le Customer Portal Stripe le fait nativement : changement plan, proration auto, nouvelle invoice. Vous recevez un webhook customer.subscription.updated. Si UI custom : stripe.subscriptions.update() avec proration_behavior: 'create_prorations'.
Comment respecter les obligations de facturation française ?
Configurer entité business (SIRET, n° TVA), activer Stripe Tax, ajouter mentions légales obligatoires dans Invoice settings. Stripe Invoices = PDF, mentions FR, n° séquentiel. Conformité OK B2C et B2B FR. Exporter régulièrement via stripe.invoices.list pour la sauvegarde 10 ans.
Quelles erreurs ne pas commettre ?
Top 5 : 1) Faire confiance au front pour confirmer le paiement (webhook = source de vérité). 2) Stocker price_id en dur. 3) Oublier de logger les events. 4) Ne pas vérifier la signature webhook. 5) Mélanger clés test et prod.
Implémentation Stripe complète sur votre SaaS ?
Setup, code production, tests, monitoring, conformité FR. SaaS sur-mesure dès 5600 €.
Voir l'offre SaaS →