Construire un SaaS B2B multi-tenant en 2026 ressemble à une discipline mature : la stack a converge, les patterns sont connus, les pièges sont documentés. Et pourtant chaque équipe que je rencontre re-fabrique les mêmes briques. Ce papier décrit la stack qu'on déploie chez Saasline pour livrer une plateforme prête en 2 à 3 semaines — sans réinventer roue, multi-tenancy, ni Stripe.
Le socle : NestJS 11 + Next.js 15
Le choix de stack tient en deux phrases. Côté serveur, NestJS 11 — DI propre, modules Stripe / BullMQ / Drizzle pré-câblés, validation au pipe, swagger sans effort. Côté client, Next.js 15 (App Router) — RSC par défaut, server actions pour les mutations, métadonnées typées par route. Les deux partagent TypeScript strict, un pnpm workspace, et @saasline/types qui exporte les DTO consommés par les deux extrémités.
La règle d'or : tout est workspaceId
Dans une plateforme SaaS multi-tenant, chaque ligne de chaque table est un risque. Une requête sans workspaceId dans le WHERE est un incident en attente. Trois lignes de défense :
- Schéma — chaque table porte une colonne
workspace_id NOT NULLavec FK cascade, indexée. Pas d'exception. - Service layer — chaque méthode reçoit
workspaceIden argument explicite, jamais lue depuis le body. Le linter custom refuse les requêtes Drizzle sans clauseeq(table.workspaceId, …). - Postgres RLS — derniere ligne de défense : un
RLS policypar table qui rejette toute requête oùcurrent_setting('app.workspace_id')ne matche pas. Le client SQL set le param avant chaque connexion.
La queue : BullMQ, pas une cron
Tout ce qui prend plus de 500 ms part en queue. Email, webhook sortant, génération PDF, agrégat analytics. BullMQ pour orchestrer (retries exponentiels, rate-limit par job-type, observabilité via Bull Board), Redis pour le backing store. Trois queues dans nos templates : email, webhook, analytics. Un worker par queue, scaling horizontal indépendant.
@Processor("webhook")
export class WebhookProcessor extends WorkerHost {
async process(job: Job<WebhookPayload>): Promise<void> {
const { url, body, secret } = job.data;
const signature = createHmac("sha256", secret).update(body).digest("hex");
await fetch(url, {
method: "POST",
body,
headers: { "X-Signature": signature, "X-Timestamp": String(Date.now()) },
});
}
}
Stripe : la lib, pas l'API REST
Une seule règle : stripe-node, jamais d'appels HTTP directs. Et toujours pinner apiVersion dans le constructeur — sinon Stripe push une nouvelle version en juin et l'app casse en silence.
La base locale est une read-replica de Stripe. Stripe est la source de vérité — chaque mise à jour d'abonnement, de facture, de paiement passe par un webhook traité de façon idempotente. Jamais l'inverse.
Webhook, pas polling. checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted — cinq events qui couvrent 95 % des cas. Chaque event est dédupliqué par event.id dans une table stripe_events, traité dans une transaction. 200 retourné en moins de 5 s, sinon Stripe retry.
Auth : JWT court + refresh long, avec rotation
Access token 15 min, refresh token 7 jours, rotation à chaque refresh. Pas de session côté serveur — on stay stateless pour pouvoir scale horizontalement sans Redis pour les sessions. Les tokens transitent en httpOnly cookies (pas localStorage, jamais), SameSite=Lax, Secure en prod.
Les cinq erreurs qu'on évite à chaque fois
- Sessions en DB. Coûteux, lent, aucun bénéfice sécuritaire vs JWT bien fait. JWT.
CASCADEpartout. Trop dangereux en multi-tenant — un suppression accidentelle d'un workspace efface tout.RESTRICTpar défaut,CASCADEseulement quand l'audit confirme que c'est désiré.- Webhook sortants synchrones. Toujours en queue, signés, retry. Le destinataire est down ? Le delivery est rejouable.
- Migration sans backup ou shadow-test. Drizzle generate-only en dev, validation manuelle du SQL avant chaque push prod, snapshot DB juste avant.
- Pas de
workspaceIddans les logs. Quand un incident arrive on perd 30 min à corréler. Chaque log structuré porte{ workspaceId, userId, requestId }au minimum.
Ce qu'on ne fait plus
- GraphQL sur de l'API B2B classique. REST + DTO typés avec OpenAPI suffit, et l'outillage est plus mûr.
- Microservices sur la première année. Un monolithe NestJS bien découpé sert 100 k MAU sans broncher.
- NoSQL pour le primaire. Postgres règle 99 % des cas. JSONB pour la flexibilité quand le schéma bouge.
Pour aller plus loin
Si tu construis une plateforme SaaS B2B et que tu te demandes par quelle brique commencer, écris-nous à [email protected] ou réserve un créneau — on regarde ensemble si une plateforme sur-mesure ou notre socle Nest-Next est le bon point de départ.