Créer un boilerplate SaaS avec Claude Code : Next.js, auth, billing, tenants et tests
Construire un starter SaaS payant avec Claude Code : Next.js, auth, billing, tenants, audit logs, tests et checklist.
Un boilerplate SaaS est la base réutilisable d’un produit web payant : authentification, facturation, tenants, rôles, emails, dashboard, admin, journaux d’audit, variables d’environnement, tests, documentation et checklist de déploiement. Claude Code peut générer cette base rapidement, mais un starter prêt à vendre doit être plus solide qu’une simple démo.
Le raccourci dangereux consiste à considérer que “l’application tourne” suffit. Un tenant est une entreprise, un espace de travail ou un compte client dans la même application. Si la vérification de tenant est faible, un client peut accéder aux données d’un autre. RBAC signifie contrôle d’accès basé sur les rôles : owner, admin, billing, member et viewer ne doivent pas avoir les mêmes droits. Un audit log enregistre qui a fait quoi et quand. Sans lui, le support, les incidents et les ventes enterprise deviennent fragiles.
Ce guide montre comment cadrer Claude Code et structurer un starter Next.js App Router, TypeScript, Prisma, Stripe et Resend qui peut devenir un produit payant ou un template commercial. Gardez les références officielles à portée de main : Claude Code docs, Next.js docs, Auth.js, Prisma schema docs, Stripe webhooks, Resend docs et OWASP Authentication Cheat Sheet.
Pour continuer sur ClaudeCodeLab, lisez aussi l’authentification sécurisée, l’implémentation RBAC, la validation Zod et le développement d’API avec Claude Code.
Partir du produit payant
La première question n’est pas “quels composants Claude Code va générer”. La première question est “que pourra lancer la personne qui achète ce starter”. Un template avec beaucoup d’écrans mais des frontières de tenants floues vaut moins qu’un template plus petit avec tests, documentation et critères de revue.
| Cas d’usage | Base nécessaire | Chemin de monétisation |
|---|---|---|
| Micro-SaaS solo | Login OAuth, plan individuel, Stripe Checkout, dashboard d’usage | Abonnement mensuel simple |
| Outil B2B d’équipe | Tenants, invitations, rôles, responsable billing, audit logs | Prix par siège ou workspace |
| Portail de contenus ou templates | Droits d’achat, historique de téléchargement, emails, admin | Packs de templates et formations |
| Outil IA interne | SSO, validations, politique IP ou domaine, logs d’opérations | Conseil d’implémentation |
Ne promettez pas qu’un boilerplate supprime la revue juridique, fiscale, privacy ou sécurité. Les taxes Stripe, remboursements, conditions d’utilisation, politique de confidentialité, conservation des données, support et revues de permissions nécessitent encore un contrôle humain. Claude Code accélère l’implémentation, il ne remplace pas la responsabilité produit.
flowchart LR
A["Marketing site"] --> B["Auth"]
B --> C["Tenant and roles"]
C --> D["Dashboard"]
C --> E["Billing"]
C --> F["Admin"]
D --> G["Audit logs"]
E --> G
F --> G
G --> H["Tests and release checklist"]
Donner un contrat CLAUDE.md à Claude Code
Si vous demandez simplement “crée une app SaaS”, Claude Code peut mélanger UI, routes API, modèles de données et règles métier dans un gros diff. CLAUDE.md transforme le travail en contrat. En termes simples, c’est le harness : l’échafaudage qui rend le travail de l’agent vérifiable.
# CLAUDE.md
## Product goal
Build a paid SaaS starter that can be reused for real products.
Do not claim the starter removes legal, tax, privacy, or security review.
## Stack
- Next.js App Router with TypeScript
- Prisma and PostgreSQL
- Auth.js for OAuth/session integration
- Stripe Checkout and billing webhooks
- Resend for transactional email
- Vitest and Playwright for acceptance tests
## Required boundaries
- Every business record belongs to a tenantId.
- Never trust tenantId from the browser without checking membership.
- Roles are OWNER, ADMIN, BILLING, MEMBER, VIEWER.
- Billing routes require OWNER or BILLING.
- Admin routes require OWNER or ADMIN.
- State-changing routes write an audit log.
- Secrets must be read through src/lib/env.ts and never hardcoded.
- Include tests for forbidden tenant access and webhook idempotency.
## Review output
After edits, list changed files, commands run, risks, and manual checks.
Ces règles bloquent les raccourcis dangereux : croire le tenantId envoyé par le navigateur, mettre des secrets dans le code, laisser un member modifier la facturation ou changer l’état sans journal d’audit.
Organiser le starter par responsabilité
Une petite démo peut tout garder dans un dossier de route. Un SaaS non. Auth, billing, équipe, email et audit se croisent rapidement, donc la structure doit rendre les limites visibles.
src/
app/
(marketing)/
(auth)/
(dashboard)/dashboard/page.tsx
(admin)/admin/page.tsx
api/
billing/checkout/route.ts
billing/webhook/route.ts
tenants/invite/route.ts
components/
dashboard/
pricing/
ui/
lib/
auth.ts
env.ts
prisma.ts
tenant.ts
audit.ts
email.ts
stripe.ts
tests/
acceptance/saas.spec.ts
prisma/
schema.prisma
Cette organisation rend aussi les prompts plus sûrs. Vous pouvez demander “mets à jour uniquement le webhook billing et les tests liés” sans ouvrir toute l’application.
Un schéma Prisma centré sur les tenants
Le schéma suivant est un minimum pratique. Tenant représente une entreprise, un workspace ou un compte client. Membership relie un utilisateur, un tenant et un rôle. Subscription stocke l’état Stripe. AuditLog enregistre les changements importants.
// prisma/schema.prisma
enum Role {
OWNER
ADMIN
BILLING
MEMBER
VIEWER
}
enum Plan {
FREE
STARTER
PRO
}
enum SubscriptionStatus {
TRIALING
ACTIVE
PAST_DUE
CANCELED
}
model User {
id String @id @default(cuid())
email String @unique
name String?
memberships Membership[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
plan Plan @default(FREE)
memberships Membership[]
subscription Subscription?
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Membership {
id String @id @default(cuid())
userId String
tenantId String
role Role @default(MEMBER)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, tenantId])
@@index([tenantId, role])
}
model Subscription {
id String @id @default(cuid())
tenantId String @unique
stripeCustomerId String @unique
stripeSubscriptionId String? @unique
status SubscriptionStatus @default(TRIALING)
priceId String?
currentPeriodEnd DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
updatedAt DateTime @updatedAt
}
model AuditLog {
id String @id @default(cuid())
tenantId String
actorId String?
action String
metadata Json?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
actor User? @relation(fields: [actorId], references: [id])
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
}
Un produit réel ajoutera souvent factures, adresses, compteurs d’usage, API keys, conservation des données et notes de support. Le but n’est pas de tout modéliser le premier jour. Le but est de rendre la frontière tenant impossible à ignorer.
Valider les variables d’environnement
Les secrets ne doivent pas se trouver dans le code, les captures ou les commits. .env.example liste seulement les noms, et les valeurs réelles restent dans Vercel, Cloudflare, AWS, GitHub Actions ou une autre plateforme. env.ts détecte les clés manquantes dès le démarrage.
// src/lib/env.ts
import { z } from "zod";
export const env = z
.object({
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(32),
NEXT_PUBLIC_APP_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
STRIPE_PRICE_STARTER: z.string().min(1),
RESEND_API_KEY: z.string().startsWith("re_"),
EMAIL_FROM: z.string().email(),
})
.parse(process.env);
Vérifier tenant et rôle côté serveur
Le navigateur peut envoyer un tenantId, mais le serveur ne doit pas lui faire confiance. Avant de lire ou modifier une donnée métier, chargez toujours la membership côté serveur.
// src/lib/tenant.ts
import { Role } from "@prisma/client";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const roleRank: Record<Role, number> = {
VIEWER: 1,
MEMBER: 2,
BILLING: 3,
ADMIN: 4,
OWNER: 5,
};
export async function requireTenant(tenantId: string, minimumRole: Role = "MEMBER") {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const membership = await prisma.membership.findUnique({
where: {
userId_tenantId: {
userId: session.user.id,
tenantId,
},
},
include: { tenant: true },
});
if (!membership || roleRank[membership.role] < roleRank[minimumRole]) {
throw new Error("Forbidden tenant access");
}
return {
userId: session.user.id,
tenant: membership.tenant,
role: membership.role,
};
}
L’erreur fréquente est de placer tenantId dans un hidden input puis de l’utiliser directement. Un hidden input n’est pas un secret. Chaque écriture doit revérifier la membership côté serveur.
Traiter les webhooks Stripe comme répétables
Un webhook est une notification externe. Il peut arriver plusieurs fois ou en retard. Un starter sérieux vérifie la signature, exige metadata.tenantId, utilise upsert et écrit un audit log.
// src/app/api/billing/webhook/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { SubscriptionStatus } from "@prisma/client";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
function toStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
if (status === "active") return "ACTIVE";
if (status === "past_due") return "PAST_DUE";
if (status === "canceled") return "CANCELED";
return "TRIALING";
}
export async function POST(request: Request) {
const body = await request.text();
const signature = (await headers()).get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_WEBHOOK_SECRET);
} catch {
return NextResponse.json({ error: "Invalid Stripe signature" }, { status: 400 });
}
if (
event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated" ||
event.type === "customer.subscription.deleted"
) {
const subscription = event.data.object as Stripe.Subscription;
const tenantId = subscription.metadata.tenantId;
if (!tenantId || typeof subscription.customer !== "string") {
return NextResponse.json({ error: "Missing tenant metadata" }, { status: 400 });
}
await prisma.subscription.upsert({
where: { tenantId },
create: {
tenantId,
stripeCustomerId: subscription.customer,
stripeSubscriptionId: subscription.id,
status: toStatus(subscription.status),
priceId: subscription.items.data[0]?.price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
stripeSubscriptionId: subscription.id,
status: toStatus(subscription.status),
priceId: subscription.items.data[0]?.price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
await prisma.auditLog.create({
data: {
tenantId,
action: `stripe.${event.type}`,
metadata: { eventId: event.id, subscriptionId: subscription.id },
},
});
}
return NextResponse.json({ received: true });
}
Centraliser email et audit
Invitations, paiements échoués, reset de mot de passe et alertes admin utilisent l’email. Changements de billing, rôles et paramètres tenant utilisent l’audit. Des helpers partagés rendent le produit plus simple à vérifier.
// src/lib/email.ts
import { Resend } from "resend";
import { env } from "@/lib/env";
const resend = new Resend(env.RESEND_API_KEY);
export async function sendTenantInviteEmail(input: {
to: string;
tenantName: string;
inviteUrl: string;
}) {
return resend.emails.send({
from: env.EMAIL_FROM,
to: input.to,
subject: `${input.tenantName} invited you`,
html: `<p>You were invited to ${input.tenantName}.</p><p><a href="${input.inviteUrl}">Accept invite</a></p>`,
});
}
// src/lib/audit.ts
import { prisma } from "@/lib/prisma";
export async function writeAuditLog(input: {
tenantId: string;
actorId?: string;
action: string;
metadata?: Record<string, unknown>;
}) {
return prisma.auditLog.create({
data: {
tenantId: input.tenantId,
actorId: input.actorId,
action: input.action,
metadata: input.metadata,
},
});
}
Écrire les tests d’acceptation avant la finition UI
Un acceptance test vérifie que la fonctionnalité est acceptable du point de vue utilisateur. Dans un SaaS payant, les chemins de refus comptent autant que le scénario heureux.
// tests/acceptance/saas.spec.ts
import { test, expect } from "@playwright/test";
test("member cannot open billing settings", async ({ page }) => {
await page.goto("/test-login?role=MEMBER");
await page.goto("/dashboard/acme/billing");
await expect(page.getByText("Forbidden")).toBeVisible();
});
test("billing user can open billing settings", async ({ page }) => {
await page.goto("/test-login?role=BILLING");
await page.goto("/dashboard/acme/billing");
await expect(page.getByRole("heading", { name: "Billing" })).toBeVisible();
});
test("tenant switch does not leak another tenant data", async ({ page }) => {
await page.goto("/test-login?tenant=acme");
await page.goto("/dashboard/other-team/settings");
await expect(page.getByText("Forbidden")).toBeVisible();
});
Le piège habituel est de tester seulement connexion, création du workspace, paiement et dashboard. Testez aussi qu’un autre tenant est bloqué, qu’un member ne modifie pas la facturation et qu’un webhook répété ne corrompt pas l’abonnement.
Checklist de lancement
Avant de publier ou vendre le starter, vérifiez au moins ces points.
- Metadata, hero image, liens officiels, liens internes et CTA sont présents
.env.examplecontient des noms mais aucun secret réel- Chaque mise à jour métier vérifie la membership tenant côté serveur
- OWNER, ADMIN, BILLING, MEMBER et VIEWER ont des tests distincts
- Stripe Webhook vérifie la signature et gère l’idempotence
- Les échecs email ont une relance ou un renvoi manuel
- Admin et audit logs fonctionnent en production
- CGU, privacy, refunds, taxes et rétention ont été revus par des humains
- README documente setup local, seed, tests et déploiement
Le vendre comme produit
Pour vendre un boilerplate SaaS, le code ne suffit pas. Les acheteurs ont besoin de documentation de setup, carte des variables d’environnement, configuration Stripe, seed data, tests d’acceptation et limites de support.
| Pack | Contenu | Idéal pour |
|---|---|---|
| Free checklist | Exemple CLAUDE.md, carte env, checklist release | Builders qui valident l’idée |
| Starter template | Next.js, Prisma, Auth.js, Stripe, Resend, tests | MVP en un week-end |
| Pro template | Admin, audit logs, usage billing, invitations, docs | Lancer un SaaS payant sérieusement |
| Team rollout | Revue de repo, formation Claude Code, règles de review | Équipes adoptant Claude Code |
ClaudeCodeLab propose une fiche gratuite, des produits et templates Claude Code et de la formation ou consultation d’implémentation. Commencez avec le gratuit pour valider le flux, puis passez aux templates ou au conseil quand le starter doit s’adapter à un vrai dépôt.
Résultat en pratique
Dans un petit dépôt de validation, Masa a d’abord demandé trop largement : “crée le dashboard”. L’écran fonctionnait, mais l’accès direct à l’URL d’un autre tenant n’était pas bloqué et le webhook Stripe ne vérifiait pas la signature. Après avoir déplacé les frontières tenant, rôles, audit logs et tests d’acceptation dans CLAUDE.md, Claude Code a produit des diffs plus petits et la revue avait une checklist claire. La leçon est simple : la valeur d’un boilerplate SaaS vient des frontières, des tests et de la documentation d’exploitation, pas de la liste de fonctionnalités la plus longue.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.