StripeNext.jsNestJSTypeScriptPaiement

Stripe avec Next.js et NestJS : intégrez le paiement en ligne étape par étape

Intégrez Stripe dans une app Next.js + NestJS : Checkout Session, webhooks sécurisés et confirmation de paiement. Guide complet avec TypeScript.

AMAlexis Mouchon12 min de lecture

Ajouter le paiement en ligne à une application web est l'une des étapes les plus délicates d'un projet. Mal géré, c'est une source de bugs silencieux et de failles de sécurité. Bien géré, avec Stripe, c'est en revanche une intégration propre, fiable et testable en quelques heures.

Dans ce guide, on va construire une intégration Stripe complète dans une stack Next.js (App Router) + NestJS : création de la Checkout Session côté backend, redirection depuis le frontend, et traitement des webhooks pour confirmer les paiements de façon sécurisée.

Pourquoi Stripe plutôt qu'une autre solution ?

Stripe est devenu la référence pour une bonne raison : son API est cohérente, sa documentation est exemplaire, et son SDK TypeScript est maintenu de première main. Comparé à PayPal ou Mollie, Stripe offre une expérience développeur nettement supérieure — surtout pour les intégrations custom.

Pour ce guide, on utilisera le flux Checkout Session : Stripe héberge la page de paiement, on gère uniquement la logique métier côté serveur. C'est l'approche recommandée pour la conformité PCI DSS — vous ne manipulez jamais les données de carte.

Installation et configuration

Côté NestJS

npm install stripe @nestjs/config

Ajoutez vos clés Stripe dans .env :

STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
FRONTEND_URL=http://localhost:3000

Créez un module dédié StripeModule pour centraliser la configuration :

// src/stripe/stripe.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { StripeService } from './stripe.service';
import { StripeController } from './stripe.controller';
import Stripe from 'stripe';

export const STRIPE_CLIENT = 'STRIPE_CLIENT';

@Module({
  imports: [ConfigModule],
  controllers: [StripeController],
  providers: [
    {
      provide: STRIPE_CLIENT,
      useFactory: (config: ConfigService) =>
        new Stripe(config.get<string>('STRIPE_SECRET_KEY')!, {
          apiVersion: '2024-06-20',
          typescript: true,
        }),
      inject: [ConfigService],
    },
    StripeService,
  ],
  exports: [StripeService],
})
export class StripeModule {}

Côté Next.js

npm install @stripe/stripe-js stripe
# .env.local
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxx

Créer une Checkout Session depuis NestJS

Le principe : le frontend envoie une requête POST à votre API NestJS, qui crée la session Stripe et retourne son URL. Jamais de clé secrète côté client.

// src/stripe/stripe.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import { STRIPE_CLIENT } from './stripe.module';

export interface CreateCheckoutDto {
  priceId: string;
  quantity?: number;
  userId: string;
  customerEmail: string;
}

@Injectable()
export class StripeService {
  constructor(
    @Inject(STRIPE_CLIENT) private readonly stripe: Stripe,
    private readonly config: ConfigService,
  ) {}

  async createCheckoutSession(dto: CreateCheckoutDto): Promise<string> {
    const frontendUrl = this.config.get<string>('FRONTEND_URL');

    const session = await this.stripe.checkout.sessions.create({
      mode: 'payment',
      line_items: [
        {
          price: dto.priceId,
          quantity: dto.quantity ?? 1,
        },
      ],
      customer_email: dto.customerEmail,
      metadata: {
        userId: dto.userId,
      },
      success_url: `${frontendUrl}/paiement/succes?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${frontendUrl}/paiement/annule`,
    });

    return session.url!;
  }
}
// src/stripe/stripe.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { StripeService, CreateCheckoutDto } from './stripe.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

@Controller('stripe')
export class StripeController {
  constructor(private readonly stripeService: StripeService) {}

  @Post('checkout')
  @UseGuards(JwtAuthGuard)
  async createCheckout(@Body() dto: CreateCheckoutDto) {
    const url = await this.stripeService.createCheckoutSession(dto);
    return { url };
  }
}

Si vous avez besoin de gérer l'authentification JWT dans NestJS, j'ai détaillé la mise en place des Guards dans l'article Guards NestJS : sécurisez vos endpoints avec JWT et des rôles personnalisés.

Rediriger l'utilisateur depuis Next.js

Côté frontend, il suffit d'appeler l'endpoint NestJS puis de rediriger vers l'URL Stripe retournée :

// app/checkout/page.tsx
'use client';

import { useState } from 'react';

interface CheckoutButtonProps {
  priceId: string;
}

export default function CheckoutButton({ priceId }: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false);

  const handleCheckout = async () => {
    setLoading(true);

    try {
      const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/stripe/checkout`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${localStorage.getItem('token')}`,
        },
        body: JSON.stringify({
          priceId,
          userId: 'user-123',
          customerEmail: 'client@example.com',
        }),
      });

      const { url } = await res.json();
      window.location.href = url;
    } catch (err) {
      console.error('Erreur lors du checkout :', err);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleCheckout}
      disabled={loading}
      className="rounded-lg bg-indigo-600 px-6 py-3 text-white font-medium disabled:opacity-50 hover:bg-indigo-700 transition"
    >
      {loading ? 'Redirection...' : 'Payer maintenant'}
    </button>
  );
}

La page de succès peut récupérer l'ID de session pour afficher un récapitulatif :

// app/paiement/succes/page.tsx
import { stripe } from '@/lib/stripe';

interface Props {
  searchParams: { session_id: string };
}

export default async function SuccessPage({ searchParams }: Props) {
  const session = await stripe.checkout.sessions.retrieve(searchParams.session_id);

  return (
    <div className="max-w-md mx-auto mt-20 text-center">
      <h1 className="text-2xl font-bold text-green-600">Paiement confirmé !</h1>
      <p className="mt-4 text-gray-600">
        Merci pour votre achat. Un email de confirmation a été envoyé à{' '}
        <strong>{session.customer_email}</strong>.
      </p>
    </div>
  );
}
// lib/stripe.ts (utilitaire partagé Next.js)
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
  typescript: true,
});

Webhooks NestJS : la partie critique

Le webhook, c'est là où beaucoup de développeurs font des erreurs. L'idée : Stripe appelle votre endpoint à chaque événement (paiement réussi, remboursé, abonnement résilié...). Vous devez vérifier la signature de chaque requête pour vous assurer qu'elle vient bien de Stripe.

Le piège du body parser

NestJS parse automatiquement les requêtes JSON. Mais pour vérifier la signature Stripe, vous avez besoin du corps brut (Buffer), pas du JSON parsé. Il faut donc désactiver le body parser global pour cet endpoint spécifique.

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { json } from 'express';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    rawBody: true, // ← indispensable pour les webhooks Stripe
  });

  app.use(
    '/stripe/webhook',
    json({
      verify: (req: any, _res, buf) => {
        req.rawBody = buf;
      },
    }),
  );

  await app.listen(3001);
}
bootstrap();

L'endpoint webhook

// src/stripe/stripe.controller.ts (ajout du webhook)
import {
  Body,
  Controller,
  Headers,
  Post,
  RawBodyRequest,
  Req,
  UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { StripeService, CreateCheckoutDto } from './stripe.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

@Controller('stripe')
export class StripeController {
  constructor(private readonly stripeService: StripeService) {}

  @Post('checkout')
  @UseGuards(JwtAuthGuard)
  async createCheckout(@Body() dto: CreateCheckoutDto) {
    const url = await this.stripeService.createCheckoutSession(dto);
    return { url };
  }

  @Post('webhook')
  async handleWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Headers('stripe-signature') signature: string,
  ) {
    return this.stripeService.handleWebhookEvent(req.rawBody!, signature);
  }
}
// src/stripe/stripe.service.ts (ajout du handler webhook)

async handleWebhookEvent(rawBody: Buffer, signature: string): Promise<void> {
  const webhookSecret = this.config.get<string>('STRIPE_WEBHOOK_SECRET')!;

  let event: Stripe.Event;

  try {
    event = this.stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch (err) {
    throw new Error(`Signature webhook invalide : ${(err as Error).message}`);
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      await this.onCheckoutCompleted(session);
      break;
    }

    case 'payment_intent.payment_failed': {
      const intent = event.data.object as Stripe.PaymentIntent;
      console.warn(`Paiement échoué : ${intent.id}`);
      break;
    }

    default:
      console.log(`Événement non géré : ${event.type}`);
  }
}

private async onCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
  const userId = session.metadata?.userId;

  if (!userId) {
    console.error('userId manquant dans les métadonnées Stripe');
    return;
  }

  // Ici : marquer la commande comme payée en BDD, envoyer un email, etc.
  console.log(`Paiement validé pour l'utilisateur ${userId}`);
}

Tester les webhooks en local

Stripe fournit la CLI pour simuler des événements en développement :

# Installer la CLI Stripe
brew install stripe/stripe-cli/stripe

# Écouter et forwarder vers votre serveur local
stripe listen --forward-to localhost:3001/stripe/webhook

# Dans un autre terminal, simuler un paiement réussi
stripe trigger checkout.session.completed

La CLI affiche le STRIPE_WEBHOOK_SECRET à utiliser en local (différent de celui de production).

Gérer les abonnements (mode subscription)

Si vous construisez un SaaS, le mode subscription remplace payment dans la Checkout Session. La logique de webhook change légèrement :

// Checkout en mode abonnement
const session = await this.stripe.checkout.sessions.create({
  mode: 'subscription', // ← ici
  line_items: [{ price: dto.priceId, quantity: 1 }],
  customer_email: dto.customerEmail,
  metadata: { userId: dto.userId },
  success_url: `${frontendUrl}/dashboard?upgraded=true`,
  cancel_url: `${frontendUrl}/tarifs`,
});

Côté webhooks, les événements à écouter deviennent customer.subscription.created, customer.subscription.deleted, invoice.payment_succeeded...

Points de sécurité à ne pas oublier

Validez toujours côté serveur. Ne faites jamais confiance à ce que le frontend vous envoie concernant un montant ou un statut de paiement. Le seul état de paiement fiable est celui remonté par le webhook Stripe.

Idempotez vos webhooks. Stripe peut envoyer le même événement plusieurs fois en cas de timeout. Avant d'exécuter une action (ex : créditer un compte), vérifiez que vous n'avez pas déjà traité cet event.id en base.

// Exemple de vérification d'idempotence
const alreadyProcessed = await this.orderModel.findOne({ stripeEventId: event.id });
if (alreadyProcessed) return;

await this.orderModel.create({ stripeEventId: event.id, userId, status: 'paid' });

Ne loguez jamais les données de carte. Stripe ne vous les transmet pas, mais soyez vigilant sur ce que vous loguez depuis les objets Stripe (customer, payment_intent, etc.).

Ce que vous avez construit

À l'issue de ce guide, vous avez une intégration Stripe production-ready avec :

Pour aller plus loin, explorez Stripe Billing pour les abonnements avancés, ou Stripe Connect si votre plateforme encaisse pour le compte de tiers.

Si vous utilisez des emails transactionnels pour confirmer les paiements, jetez un œil à mon article sur Resend et React Email dans Next.js et NestJS — les deux s'associent très bien.


Vous avez un projet qui nécessite une intégration de paiement, un abonnement SaaS ou une boutique en ligne ? N'hésitez pas à me contacter pour en discuter — je serai ravi de vous aider à construire quelque chose de solide.