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)

Service Visionary

Intégration Stripe complète sur votre SaaS — devis 24h

1. Setup compte Stripe France

  1. Créer le compte sur dashboard.stripe.com, choisir France comme pays.
  2. Renseigner l'entité business : raison sociale, SIRET, adresse, RIB pour les payouts.
  3. Activer la TVA : Settings → Tax → activer Stripe Tax (renseigner votre n° TVA FR).
  4. Configurer les Invoice settings : mentions légales obligatoires (taux pénalité retard, conditions remboursement, mentions auto-entrepreneur si applicable).
  5. 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) :

ProductPrice IDRécurrenceMontant
Starterprice_starter_monthlyMensuel9 € HT
Starterprice_starter_yearlyAnnuel90 € HT (–17 %)
Proprice_pro_monthlyMensuel29 € HT
Proprice_pro_yearlyAnnuel290 € 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 :

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.

Boilerplate complet · ZIP téléchargeable

Recevez le boilerplate Next.js 15 + Stripe

Code production-ready : Next.js 15 App Router, Prisma + Postgres, Stripe Checkout, webhooks idempotents, Customer Portal, Stripe Tax, tests Vitest, README détaillé.

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 :

10. Bascule en production — checklist

  1. Activer le compte (KYC validé, RIB renseigné, identité confirmée)
  2. Recréer les Products / Prices côté live (les test ne migrent pas)
  3. Mettre à jour .env.production avec les clés sk_live_… et nouveaux price_…
  4. Configurer le webhook endpoint en live (Stripe Dashboard → Developers → Webhooks → Add endpoint, sélectionner les 6 events critiques)
  5. Récupérer le webhook secret live et l'ajouter à STRIPE_WEBHOOK_SECRET
  6. Tester un paiement en mode live avec une vraie carte (en remboursement immédiat possible)
  7. Configurer Customer Portal en live (les settings test ne migrent pas)
  8. Activer Stripe Tax en mode live
  9. Vérifier les Invoice settings (mentions légales FR)
  10. Mettre en place le monitoring : alertes Slack / email sur webhook errors via Stripe Dashboard

11. Erreurs typiques à éviter

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 →