Construire un Starter Kit SaaS avec Next.js 15, Stripe et Auth.js v5

Noqta TeamAI Bot
Par Noqta Team & AI Bot ·

Chargement du lecteur de synthèse vocale...

Construisez votre propre systeme de facturation SaaS de zero. Ce tutoriel vous guide pas a pas dans la creation d'une application d'abonnement complete avec Next.js 15 Server Actions, Stripe Checkout, le portail client et Auth.js v5 — tout ce dont vous avez besoin pour lancer un vrai produit SaaS.

Ce que vous allez apprendre

A la fin de ce tutoriel, vous saurez :

  • Configurer Auth.js v5 avec les fournisseurs GitHub et Google OAuth
  • Integrer Stripe Checkout pour la facturation par abonnement
  • Construire une page de tarification avec plusieurs niveaux de plan
  • Gerer les webhooks Stripe pour synchroniser l'etat des abonnements
  • Creer des routes protegees avec middleware et verification de session
  • Implementer un portail client pour la gestion des abonnements
  • Stocker les donnees utilisateurs et abonnements avec Drizzle ORM et PostgreSQL

Prerequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ installe
  • Un compte Stripe (gratuit sur stripe.com)
  • Une application GitHub OAuth ou un projet Google Cloud pour l'authentification
  • PostgreSQL en local (ou une instance hebergee comme Neon ou Supabase)
  • Une connaissance de base de Next.js et TypeScript

Vue d'ensemble de l'architecture

┌─────────────────────────────────────────────┐
│                  Next.js 15                  │
│                                              │
│  ┌──────────┐  ┌──────────┐  ┌───────────┐  │
│  │ Auth.js  │  │  Stripe  │  │  Drizzle  │  │
│  │   v5     │  │ Checkout │  │    ORM    │  │
│  └────┬─────┘  └────┬─────┘  └─────┬─────┘  │
│       │              │              │         │
│  ┌────▼──────────────▼──────────────▼─────┐  │
│  │           Server Actions               │  │
│  └────────────────────────────────────────┘  │
└──────────────────┬───────────────────────────┘
                   │
         ┌─────────▼─────────┐
         │    PostgreSQL      │
         │  (Users, Subs)     │
         └───────────────────┘

Etape 1 : Creer le projet Next.js

Commencez par creer une nouvelle application Next.js 15 avec TypeScript et App Router :

npx create-next-app@latest saas-starter --typescript --tailwind --eslint --app --src-dir
cd saas-starter

Installez les dependances necessaires :

npm install next-auth@beta @auth/drizzle-adapter drizzle-orm @neondatabase/serverless stripe
npm install -D drizzle-kit dotenv

Creez votre fichier .env.local avec les variables d'environnement requises :

# Base de donnees
DATABASE_URL="postgresql://user:password@localhost:5432/saas_starter"
 
# Auth.js
AUTH_SECRET="run-npx-auth-secret-to-generate"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
 
# Stripe
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
 
# Application
NEXT_PUBLIC_APP_URL="http://localhost:3000"

Generez votre AUTH_SECRET en executant npx auth secret dans votre terminal. Ne commitez jamais cette valeur dans le controle de version.

Etape 2 : Configurer le schema de base de donnees avec Drizzle ORM

Creez le schema de base de donnees qui gere a la fois les sessions Auth.js et les abonnements Stripe.

Creez src/db/schema.ts :

import {
  pgTable,
  text,
  timestamp,
  integer,
  primaryKey,
  boolean,
} from "drizzle-orm/pg-core";
import type { AdapterAccountType } from "next-auth/adapters";
 
// Tables requises par Auth.js
export const users = pgTable("users", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
  name: text("name"),
  email: text("email").unique(),
  emailVerified: timestamp("emailVerified", { mode: "date" }),
  image: text("image"),
  stripeCustomerId: text("stripe_customer_id").unique(),
  stripePriceId: text("stripe_price_id"),
  stripeSubscriptionId: text("stripe_subscription_id").unique(),
  stripeSubscriptionStatus: text("stripe_subscription_status"),
  createdAt: timestamp("created_at", { mode: "date" }).defaultNow(),
});
 
export const accounts = pgTable(
  "accounts",
  {
    userId: text("userId")
      .notNull()
      .references(() => users.id, { onDelete: "cascade" }),
    type: text("type").$type<AdapterAccountType>().notNull(),
    provider: text("provider").notNull(),
    providerAccountId: text("providerAccountId").notNull(),
    refresh_token: text("refresh_token"),
    access_token: text("access_token"),
    expires_at: integer("expires_at"),
    token_type: text("token_type"),
    scope: text("scope"),
    id_token: text("id_token"),
    session_state: text("session_state"),
  },
  (account) => [
    primaryKey({
      columns: [account.provider, account.providerAccountId],
    }),
  ]
);
 
export const sessions = pgTable("sessions", {
  sessionToken: text("sessionToken").primaryKey(),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  expires: timestamp("expires", { mode: "date" }).notNull(),
});
 
export const verificationTokens = pgTable(
  "verificationTokens",
  {
    identifier: text("identifier").notNull(),
    token: text("token").notNull(),
    expires: timestamp("expires", { mode: "date" }).notNull(),
  },
  (verificationToken) => [
    primaryKey({
      columns: [verificationToken.identifier, verificationToken.token],
    }),
  ]
);

Creez src/db/index.ts pour la connexion a la base de donnees :

import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
 
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

Creez drizzle.config.ts a la racine du projet :

import { defineConfig } from "drizzle-kit";
import dotenv from "dotenv";
 
dotenv.config({ path: ".env.local" });
 
export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Executez la migration :

npx drizzle-kit push

Etape 3 : Configurer Auth.js v5

Creez src/auth.ts :

import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: DrizzleAdapter(db),
  providers: [GitHub, Google],
  callbacks: {
    session({ session, user }) {
      session.user.id = user.id;
      return session;
    },
  },
  pages: {
    signIn: "/login",
  },
});

Creez la route API dans src/app/api/auth/[...nextauth]/route.ts :

import { handlers } from "@/auth";
 
export const { GET, POST } = handlers;

Creez la page de connexion dans src/app/login/page.tsx :

import { signIn } from "@/auth";
 
export default function LoginPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-sm space-y-6 rounded-xl border p-8 shadow-sm">
        <div className="text-center">
          <h1 className="text-2xl font-bold">Bon retour</h1>
          <p className="mt-2 text-sm text-gray-500">
            Connectez-vous a votre compte pour continuer
          </p>
        </div>
 
        <form
          action={async () => {
            "use server";
            await signIn("github", { redirectTo: "/dashboard" });
          }}
        >
          <button
            type="submit"
            className="flex w-full items-center justify-center gap-2 rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800"
          >
            Continuer avec GitHub
          </button>
        </form>
 
        <form
          action={async () => {
            "use server";
            await signIn("google", { redirectTo: "/dashboard" });
          }}
        >
          <button
            type="submit"
            className="flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium hover:bg-gray-50"
          >
            Continuer avec Google
          </button>
        </form>
      </div>
    </div>
  );
}

Etape 4 : Proteger les routes avec Middleware

Creez src/middleware.ts pour proteger les routes authentifiees :

import { auth } from "@/auth";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");
  const isOnLogin = req.nextUrl.pathname === "/login";
 
  if (isOnDashboard && !isLoggedIn) {
    return Response.redirect(new URL("/login", req.nextUrl));
  }
 
  if (isOnLogin && isLoggedIn) {
    return Response.redirect(new URL("/dashboard", req.nextUrl));
  }
});
 
export const config = {
  matcher: ["/dashboard/:path*", "/login"],
};

Etape 5 : Configurer les produits et tarifs Stripe

Avant d'ecrire le code, creez vos produits dans le tableau de bord Stripe ou utilisez le CLI Stripe :

# Installer Stripe CLI
brew install stripe/stripe-cli/stripe
 
# Se connecter a votre compte Stripe
stripe login
 
# Creer les produits et les tarifs
stripe products create --name="Starter" --description="Pour les particuliers et petits projets"
stripe prices create --product=prod_xxx --unit-amount=900 --currency=usd --recurring[interval]=month
 
stripe products create --name="Pro" --description="Pour les equipes et entreprises en croissance"
stripe prices create --product=prod_yyy --unit-amount=2900 --currency=usd --recurring[interval]=month
 
stripe products create --name="Enterprise" --description="Pour les grandes organisations"
stripe prices create --product=prod_zzz --unit-amount=9900 --currency=usd --recurring[interval]=month

Creez src/lib/stripe.ts :

import Stripe from "stripe";
 
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-12-18.acacia",
  typescript: true,
});
 
export const PLANS = [
  {
    name: "Starter",
    description: "Pour les particuliers et petits projets",
    price: "9 $",
    priceId: process.env.STRIPE_STARTER_PRICE_ID!,
    features: [
      "Jusqu'a 3 projets",
      "Analytique de base",
      "Support par email",
      "1 Go de stockage",
    ],
  },
  {
    name: "Pro",
    description: "Pour les equipes en croissance",
    price: "29 $",
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    popular: true,
    features: [
      "Projets illimites",
      "Analytique avancee",
      "Support prioritaire",
      "10 Go de stockage",
      "Collaboration d'equipe",
      "Integrations personnalisees",
    ],
  },
  {
    name: "Enterprise",
    description: "Pour les grandes organisations",
    price: "99 $",
    priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
    features: [
      "Tout dans Pro",
      "Stockage illimite",
      "Support dedie",
      "SSO et SAML",
      "Contrats personnalises",
      "Garanties SLA",
    ],
  },
] as const;

Etape 6 : Construire la page de tarification

Creez src/app/pricing/page.tsx :

import { auth } from "@/auth";
import { PLANS } from "@/lib/stripe";
import { CheckoutButton } from "./checkout-button";
 
export default async function PricingPage() {
  const session = await auth();
 
  return (
    <div className="mx-auto max-w-5xl px-4 py-16">
      <div className="text-center">
        <h1 className="text-4xl font-bold tracking-tight">
          Tarification simple et transparente
        </h1>
        <p className="mt-4 text-lg text-gray-500">
          Choisissez le plan qui correspond a vos besoins. Changez de plan a
          tout moment.
        </p>
      </div>
 
      <div className="mt-16 grid gap-8 md:grid-cols-3">
        {PLANS.map((plan) => (
          <div
            key={plan.name}
            className={`relative rounded-2xl border p-8 shadow-sm ${
              plan.popular
                ? "border-blue-600 ring-2 ring-blue-600"
                : "border-gray-200"
            }`}
          >
            {plan.popular && (
              <span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-blue-600 px-3 py-1 text-xs font-semibold text-white">
                Le plus populaire
              </span>
            )}
 
            <h3 className="text-lg font-semibold">{plan.name}</h3>
            <p className="mt-1 text-sm text-gray-500">{plan.description}</p>
 
            <p className="mt-6">
              <span className="text-4xl font-bold">{plan.price}</span>
              <span className="text-gray-500">/mois</span>
            </p>
 
            <CheckoutButton
              priceId={plan.priceId}
              isLoggedIn={!!session}
              popular={plan.popular}
            />
 
            <ul className="mt-8 space-y-3">
              {plan.features.map((feature) => (
                <li key={feature} className="flex items-center gap-2 text-sm">
                  <svg
                    className="h-4 w-4 text-green-500"
                    fill="none"
                    viewBox="0 0 24 24"
                    strokeWidth={2}
                    stroke="currentColor"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      d="M4.5 12.75l6 6 9-13.5"
                    />
                  </svg>
                  {feature}
                </li>
              ))}
            </ul>
          </div>
        ))}
      </div>
    </div>
  );
}

Creez le bouton de paiement en tant que composant client dans src/app/pricing/checkout-button.tsx :

"use client";
 
import { useTransition } from "react";
import { createCheckoutSession } from "./actions";
 
interface CheckoutButtonProps {
  priceId: string;
  isLoggedIn: boolean;
  popular?: boolean;
}
 
export function CheckoutButton({
  priceId,
  isLoggedIn,
  popular,
}: CheckoutButtonProps) {
  const [isPending, startTransition] = useTransition();
 
  const handleCheckout = () => {
    startTransition(async () => {
      const { url } = await createCheckoutSession(priceId);
      if (url) window.location.href = url;
    });
  };
 
  return (
    <button
      onClick={handleCheckout}
      disabled={isPending}
      className={`mt-6 w-full rounded-lg px-4 py-2.5 text-sm font-semibold ${
        popular
          ? "bg-blue-600 text-white hover:bg-blue-700"
          : "bg-gray-900 text-white hover:bg-gray-800"
      } disabled:opacity-50`}
    >
      {isPending ? "Chargement..." : isLoggedIn ? "S'abonner" : "Commencer"}
    </button>
  );
}

Etape 7 : Creer les Server Actions pour le paiement

Creez src/app/pricing/actions.ts :

"use server";
 
import { auth } from "@/auth";
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
 
export async function createCheckoutSession(priceId: string) {
  const session = await auth();
 
  if (!session?.user?.id) {
    redirect("/login");
  }
 
  // Obtenir ou creer un client Stripe
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id));
 
  let customerId = user.stripeCustomerId;
 
  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email!,
      name: user.name ?? undefined,
      metadata: { userId: user.id },
    });
 
    await db
      .update(users)
      .set({ stripeCustomerId: customer.id })
      .where(eq(users.id, user.id));
 
    customerId = customer.id;
  }
 
  // Creer la session de paiement
  const checkoutSession = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    payment_method_types: ["card"],
    line_items: [{ price: 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`,
    subscription_data: {
      metadata: { userId: session.user.id },
    },
  });
 
  return { url: checkoutSession.url };
}
 
export async function createCustomerPortalSession() {
  const session = await auth();
 
  if (!session?.user?.id) {
    redirect("/login");
  }
 
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id));
 
  if (!user.stripeCustomerId) {
    redirect("/pricing");
  }
 
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  });
 
  return { url: portalSession.url };
}

Etape 8 : Gerer les webhooks Stripe

C'est la partie la plus critique. Les webhooks Stripe maintiennent votre base de donnees synchronisee avec les changements d'abonnement.

Creez src/app/api/webhooks/stripe/route.ts :

import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import type Stripe from "stripe";
 
export async function POST(req: Request) {
  const body = await req.text();
  const headersList = await headers();
  const signature = headersList.get("stripe-signature")!;
 
  let event: Stripe.Event;
 
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error("Echec de la verification de signature webhook:", err);
    return new Response("Webhook Error", { status: 400 });
  }
 
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const subscription = await stripe.subscriptions.retrieve(
        session.subscription as string
      );
 
      const userId = subscription.metadata.userId;
 
      await db
        .update(users)
        .set({
          stripeSubscriptionId: subscription.id,
          stripePriceId: subscription.items.data[0].price.id,
          stripeSubscriptionStatus: subscription.status,
        })
        .where(eq(users.id, userId));
 
      break;
    }
 
    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
 
      await db
        .update(users)
        .set({
          stripePriceId: subscription.items.data[0].price.id,
          stripeSubscriptionStatus: subscription.status,
        })
        .where(eq(users.stripeSubscriptionId, subscription.id));
 
      break;
    }
 
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
 
      await db
        .update(users)
        .set({
          stripePriceId: null,
          stripeSubscriptionId: null,
          stripeSubscriptionStatus: "canceled",
        })
        .where(eq(users.stripeSubscriptionId, subscription.id));
 
      break;
    }
  }
 
  return new Response("OK", { status: 200 });
}

Pour tester les webhooks localement, utilisez le CLI Stripe :

stripe listen --forward-to localhost:3000/api/webhooks/stripe

Gardez le CLI Stripe en marche dans un terminal separe pendant le developpement. Il transmettra les evenements webhook a votre serveur local et vous donnera un secret de signature webhook a utiliser dans .env.local.

Etape 9 : Construire le tableau de bord

Creez le tableau de bord protege dans src/app/dashboard/page.tsx :

import { auth, signOut } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { PLANS } from "@/lib/stripe";
import { ManageSubscriptionButton } from "./manage-subscription-button";
 
export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user?.id) redirect("/login");
 
  const [user] = await db
    .select()
    .from(users)
    .where(eq(users.id, session.user.id));
 
  const currentPlan = PLANS.find((p) => p.priceId === user.stripePriceId);
  const isActive = user.stripeSubscriptionStatus === "active";
 
  return (
    <div className="mx-auto max-w-4xl px-4 py-16">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold">Tableau de bord</h1>
          <p className="text-gray-500">Bon retour, {session.user.name}</p>
        </div>
        <form
          action={async () => {
            "use server";
            await signOut({ redirectTo: "/" });
          }}
        >
          <button
            type="submit"
            className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50"
          >
            Se deconnecter
          </button>
        </form>
      </div>
 
      <div className="mt-8 rounded-xl border p-6">
        <h2 className="text-lg font-semibold">Abonnement</h2>
 
        {isActive && currentPlan ? (
          <div className="mt-4 space-y-4">
            <div className="flex items-center gap-3">
              <span className="rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-700">
                Actif
              </span>
              <span className="font-medium">Plan {currentPlan.name}</span>
              <span className="text-gray-500">
                {currentPlan.price}/mois
              </span>
            </div>
            <ManageSubscriptionButton />
          </div>
        ) : (
          <div className="mt-4 space-y-4">
            <p className="text-gray-500">
              Vous etes actuellement sur le niveau gratuit.
            </p>
            <a
              href="/pricing"
              className="inline-block rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700"
            >
              Passer au niveau superieur
            </a>
          </div>
        )}
      </div>
 
      <div className="mt-8 rounded-xl border p-6">
        <h2 className="text-lg font-semibold">Compte</h2>
        <div className="mt-4 space-y-2 text-sm">
          <p>
            <span className="text-gray-500">Email :</span> {user.email}
          </p>
          <p>
            <span className="text-gray-500">Membre depuis :</span>{" "}
            {user.createdAt?.toLocaleDateString("fr-FR")}
          </p>
        </div>
      </div>
    </div>
  );
}

Creez src/app/dashboard/manage-subscription-button.tsx :

"use client";
 
import { useTransition } from "react";
import { createCustomerPortalSession } from "../pricing/actions";
 
export function ManageSubscriptionButton() {
  const [isPending, startTransition] = useTransition();
 
  return (
    <button
      onClick={() => {
        startTransition(async () => {
          const { url } = await createCustomerPortalSession();
          if (url) window.location.href = url;
        });
      }}
      disabled={isPending}
      className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50"
    >
      {isPending ? "Chargement..." : "Gerer l'abonnement"}
    </button>
  );
}

Etape 10 : Ajouter des verifications d'abonnement a votre API

Creez un utilitaire reutilisable dans src/lib/subscription.ts pour restreindre les fonctionnalites selon le plan :

import { auth } from "@/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
 
export async function getUserSubscription() {
  const session = await auth();
  if (!session?.user?.id) return null;
 
  const [user] = await db
    .select({
      stripePriceId: users.stripePriceId,
      stripeSubscriptionStatus: users.stripeSubscriptionStatus,
    })
    .from(users)
    .where(eq(users.id, session.user.id));
 
  return {
    isActive: user?.stripeSubscriptionStatus === "active",
    priceId: user?.stripePriceId,
  };
}
 
export async function requireSubscription() {
  const sub = await getUserSubscription();
  if (!sub?.isActive) {
    throw new Error("Un abonnement actif est requis");
  }
  return sub;
}

Utilisez-le dans n'importe quel Server Action ou route API :

"use server";
 
import { requireSubscription } from "@/lib/subscription";
 
export async function premiumFeatureAction() {
  await requireSubscription(); // Lance une erreur si non abonne
 
  // Votre logique premium ici
}

Tester votre implementation

1. Tester le flux d'authentification

  1. Demarrez le serveur de developpement : npm run dev
  2. Visitez http://localhost:3000/login
  3. Connectez-vous avec GitHub ou Google
  4. Verifiez que vous etes redirige vers /dashboard

2. Tester le flux d'abonnement

  1. Visitez /pricing et cliquez sur un plan
  2. Utilisez la carte de test Stripe : 4242 4242 4242 4242 (date d'expiration future, n'importe quel CVC)
  3. Completez le paiement et verifiez que le tableau de bord affiche votre plan actif

3. Tester les webhooks localement

# Terminal 1 : Demarrer Next.js
npm run dev
 
# Terminal 2 : Transmettre les evenements Stripe
stripe listen --forward-to localhost:3000/api/webhooks/stripe
 
# Terminal 3 : Declencher un evenement de test
stripe trigger checkout.session.completed

Depannage

Erreur "No matching state found" pendant OAuth

Cela signifie generalement que votre AUTH_SECRET est manquant ou a change. Regenerez-le avec npx auth secret.

Les webhooks n'atteignent pas votre serveur local

Assurez-vous que le CLI Stripe est en marche avec stripe listen. Verifiez que le STRIPE_WEBHOOK_SECRET dans .env.local correspond au secret affiche par le CLI.

Le statut d'abonnement ne se met pas a jour

Verifiez les logs webhook dans le tableau de bord Stripe sous Developers > Webhooks. Assurez-vous que l'URL du endpoint webhook est correcte et que le secret de signature correspond.

Mise en production

Lors du deploiement en production, n'oubliez pas de :

  1. Configurer le endpoint webhook Stripe dans le tableau de bord Stripe pointant vers https://votre-domaine.com/api/webhooks/stripe
  2. Passer aux cles Stripe live — remplacez sk_test_ et pk_test_ par vos cles de production
  3. Configurer les URLs de redirection OAuth dans GitHub/Google pour votre domaine de production
  4. Definir toutes les variables d'environnement dans votre plateforme d'hebergement (Vercel, Railway, etc.)
  5. Activer le portail client Stripe dans votre tableau de bord Stripe sous Settings > Billing > Customer Portal

Prochaines etapes

Maintenant que vous avez un systeme de facturation SaaS fonctionnel, voici quelques idees pour l'etendre :

  • Ajouter des notifications par email avec Resend ou SendGrid pour les evenements d'abonnement
  • Implementer la facturation a l'usage avec Stripe metered billing pour les produits bases sur API
  • Ajouter la gestion d'equipes avec le support des organisations et le controle d'acces par roles
  • Construire un tableau de bord administrateur pour visualiser les metriques de revenus et les donnees clients
  • Ajouter une periode d'essai gratuite en utilisant le parametre trial_period_days de Stripe

Conclusion

Vous avez construit un kit de demarrage SaaS complet avec Next.js 15, Auth.js v5 et Stripe. Cette base vous offre l'authentification OAuth, la facturation par abonnement avec plusieurs niveaux, la gestion d'etat pilotee par webhooks et un portail en libre-service pour les clients. A partir de la, vous pouvez vous concentrer sur la construction des fonctionnalites reelles de votre produit SaaS, sachant que l'infrastructure de facturation est solide.

Les patterns utilises dans ce tutoriel — Server Actions pour les mutations, middleware pour la protection des routes, et webhooks pour la synchronisation de l'etat — representent l'approche Next.js moderne qui s'adapte bien des projets personnels aux applications de production.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur 8 Les Bases de Laravel 11 : Vues.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes

Construire un Agent IA Autonome avec Agentic RAG et Next.js

Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.

30 min read·