Réponse courte
Pour construire une application SaaS scalable en 2026, la combinaison Next.js 16 + TypeScript + PostgreSQL reste l'un des choix les plus rentables : un seul langage du navigateur à la base, un framework qui gère le rendu serveur et les API, et une base relationnelle capable d'encaisser bien plus de charge qu'un jeune produit n'en verra.
L'architecture que je recommande par défaut tient en une phrase : un monolithe modulaire, une base PostgreSQL partagée entre les clients avec une colonne organization_id protégée par la Row Level Security, une autorisation vérifiée dans la couche d'accès aux données, et tout ce qui est lent ou externe dans une file d'attente.
Ce guide s'adresse aux CTO et fondateurs qui choisissent une stack, aux développeurs qui démarrent un SaaS B2B, et aux recruteurs qui veulent comprendre comment je raisonne. Il reflète ma pratique : ce portfolio (Next.js, TypeScript, SEO technique), le projet OpsPilot (PostgreSQL, Redis, observabilité) et mes années d'alternance sur React, Node.js et PostgreSQL. Ce n'est pas le récit d'un SaaS client précis : c'est l'architecture que je propose quand on me confie un projet, avec ses limites.
Ce que « scalable » veut dire pour un SaaS
On réduit souvent la scalabilité au trafic. Pour un SaaS, trois dimensions comptent, dans cet ordre :
- L'équipe : est-ce qu'un nouveau développeur peut livrer une fonctionnalité en une semaine sans casser le reste ? C'est la contrainte qui se fait sentir en premier.
- Les données : est-ce que les données d'un client restent isolées de celles des autres, et est-ce que les requêtes restent rapides quand une table passe de 10 000 à 50 millions de lignes ?
- Le trafic : est-ce que l'application encaisse un pic sans saturer la base ?
Une architecture qui optimise le trafic au détriment de l'équipe (dix microservices pour trois développeurs) échoue plus vite qu'un monolithe bien découpé. L'objectif est donc de garder un seul déployable aussi longtemps que possible, tout en traçant des frontières nettes entre les modules pour pouvoir en extraire un le jour où un vrai besoin apparaît.
La stack de référence en 2026
| Couche | Choix par défaut | Alternative | Quand changer |
|---|---|---|---|
| Framework | Next.js 16 (App Router) | Vite + React Router | Application 100 % authentifiée, sans SEO ni rendu serveur |
| Langage | TypeScript en mode strict | — | — |
| Base de données | PostgreSQL managé (Neon, Supabase, RDS…) | — | Rarement : Postgres couvre le relationnel, le JSON, la recherche plein texte et des files simples |
| Accès aux données | Drizzle ORM | Prisma, Kysely | Prisma si l'équipe préfère son outillage ; Kysely pour du SQL typé sans ORM |
| Validation | Zod | Valibot | Quand le poids du JavaScript côté client devient critique |
| Authentification | Better Auth | Clerk, Auth.js | Clerk pour déléguer l'interface et le SSO entreprise |
| Paiement | Stripe Billing | Paddle, Lemon Squeezy | Un merchant of record qui gère la TVA à votre place |
| Tâches de fond | pg-boss ou table outbox | Inngest, Trigger.dev | Workflows longs, reprises et observabilité gérées pour vous |
| Hébergement | Vercel | Conteneurs (Fly.io, Railway, Kubernetes) | Coûts à forte charge, contraintes réseau ou de souveraineté |
| Observabilité | OpenTelemetry + Sentry | Datadog, Grafana | Volume de données ou besoin d'un outil unique |
Deux remarques sur ce tableau. D'abord, Auth.js est maintenu par l'équipe de Better Auth depuis septembre 2025, qui recommande Better Auth pour les nouveaux projets. Ensuite, Next.js 16 a renommé middleware.ts en proxy.ts, rendu Turbopack par défaut et introduit Cache Components ("use cache") : si vous lisez un tutoriel de 2024, une partie des API a changé.
Un monolithe modulaire avec Next.js
Le piège classique d'un projet Next.js est de ranger le code par type technique (components/, hooks/, utils/) jusqu'à ce que plus personne ne sache où vit la logique de facturation. Je range le code par module métier, chaque module exposant une petite surface :
src/
app/
(marketing)/ pages publiques, statiques et indexables
dashboard/[org]/ l'application, une organisation par URL
projects/page.tsx
settings/billing/page.tsx
api/webhooks/stripe/route.ts
modules/
projects/
data.ts lectures et écritures (server-only)
actions.ts Server Actions du module
schema.ts schémas Zod partagés avec les formulaires
components/
billing/
members/
db/
schema.ts tables Drizzle
client.ts
migrations/
lib/
auth.ts session et appartenance à l'organisation
tenant.ts transaction liée à l'organisation courante
proxy.ts
instrumentation.ts
Trois règles rendent ce découpage utile :
- Les pages ne parlent jamais à la base directement. Elles appellent
modules/*/data.ts, qui vérifie les droits puis lit les données. C'est la Data Access Layer recommandée par la documentation de Next.js. - Un module n'importe pas les fichiers internes d'un autre module. Si
billinga besoin demembers, il passe par une fonction exportée. Une règle ESLint (no-restricted-imports) suffit à le faire respecter. - Les composants client restent aux feuilles de l'arbre. Les Server Components chargent les données ; seuls les éléments interactifs (formulaires, menus, graphiques) passent en
"use client". On envoie moins de JavaScript, et les secrets restent sur le serveur.
Server Actions ou Route Handlers ?
Les Server Actions conviennent aux mutations déclenchées par votre propre interface : formulaires, boutons, réglages. Les Route Handlers (app/api/.../route.ts) restent nécessaires pour tout ce qui est appelé de l'extérieur : webhooks, API publique, applications mobiles, intégrations. Une Server Action est un endpoint public comme un autre : elle doit valider ses entrées et vérifier les droits, exactement comme une route d'API.
PostgreSQL multi-tenant : isoler les données des clients
C'est la décision la plus difficile à changer plus tard. Il existe trois stratégies :
| Stratégie | Isolation | Coût d'exploitation | Pour qui |
|---|---|---|---|
Base partagée + organization_id + RLS | Logique, garantie par PostgreSQL | Faible | La grande majorité des SaaS B2B |
| Un schéma par client | Moyenne | Migrations à jouer N fois | Quelques dizaines de gros clients |
| Une base par client | Forte | Élevé : connexions, migrations et sauvegardes multipliées | Exigences contractuelles ou réglementaires |
Je pars presque toujours sur la première. Le risque de cette stratégie est connu : une requête qui oublie le filtre organization_id expose les données d'un autre client. La Row Level Security de PostgreSQL ferme ce risque au niveau de la base :
create table projects (
id uuid primary key default uuidv7(),
organization_id uuid not null references organizations (id),
name text not null,
created_at timestamptz not null default now()
);
-- Les requêtes filtrent toujours par organisation : l'index commence par elle.
create index projects_org_created_idx on projects (organization_id, created_at desc);
alter table projects enable row level security;
-- Sans FORCE, le propriétaire de la table ignore les politiques.
alter table projects force row level security;
create policy tenant_isolation on projects
using (organization_id = current_setting('app.organization_id', true)::uuid)
with check (organization_id = current_setting('app.organization_id', true)::uuid);
Quelques détails qui font la différence en production :
current_setting('app.organization_id', true)renvoieNULLsi la variable n'est pas définie. La comparaison échoue alors et la requête ne renvoie rien : le système échoue fermé, pas ouvert.uuidv7()est disponible nativement depuis PostgreSQL 18. Ces identifiants sont ordonnés dans le temps, ce qui ménage les index par rapport à des UUID v4 aléatoires. Sur une version antérieure, générez-les côté application.- L'application doit se connecter avec un rôle dédié qui n'est ni superutilisateur, ni
BYPASSRLS. Les migrations utilisent un autre rôle.
Côté TypeScript, chaque accès aux données passe par une transaction qui fixe l'organisation courante :
// lib/tenant.ts
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "@/db/client";
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
export async function withTenant<T>(
organizationId: string,
work: (tx: Transaction) => Promise<T>
): Promise<T> {
return db.transaction(async (tx) => {
// Le troisième argument (true) limite la valeur à la transaction :
// rien ne fuit vers la requête suivante, même derrière un pooler.
await tx.execute(sql`select set_config('app.organization_id', ${organizationId}, true)`);
return work(tx);
});
}
Ce point est crucial avec un pooler de connexions en mode transaction (PgBouncer, Supavisor, l'URL « pooled » de Neon) : un SET de session resterait attaché à la connexion physique et contaminerait la requête d'un autre client. set_config(..., true) ne vit que le temps de la transaction.
Drizzle sait aussi déclarer les politiques dans le schéma (pgPolicy) pour qu'elles soient versionnées avec les migrations. Les écrire en SQL brut, comme ci-dessus, reste parfaitement valable ; l'essentiel est qu'elles passent en revue de code comme le reste.
Une couche d'accès aux données typée et vérifiée
La RLS est un filet de sécurité, pas un modèle d'autorisation. Savoir si un utilisateur a le droit de lire les projets d'une organisation (membre, admin, invité) relève de l'application :
// modules/projects/data.ts
import "server-only";
import { cache } from "react";
import { desc } from "drizzle-orm";
import { projects } from "@/db/schema";
import { requireMembership } from "@/lib/auth";
import { withTenant } from "@/lib/tenant";
export const listProjects = cache(async (orgSlug: string) => {
// Lit la session, vérifie l'appartenance et le rôle, sinon redirige.
const { organizationId } = await requireMembership(orgSlug, "member");
return withTenant(organizationId, (tx) =>
tx.select().from(projects).orderBy(desc(projects.createdAt)).limit(50)
);
});
cache de React déduplique l'appel au sein d'un même rendu : une page et son en-tête peuvent appeler listProjects sans doubler la requête. L'import server-only provoque une erreur de build si ce fichier finit par erreur dans un bundle client.
Pour les écritures, la Server Action valide ses entrées avec Zod avant de toucher à quoi que ce soit :
// modules/projects/actions.ts
"use server";
import { z } from "zod";
import { refresh } from "next/cache";
import { projects } from "@/db/schema";
import { requireMembership } from "@/lib/auth";
import { withTenant } from "@/lib/tenant";
const createProjectInput = z.object({
orgSlug: z.string().min(1),
name: z.string().trim().min(2).max(80),
});
export async function createProject(formData: FormData) {
const input = createProjectInput.parse({
orgSlug: formData.get("orgSlug"),
name: formData.get("name"),
});
const { organizationId } = await requireMembership(input.orgSlug, "admin");
await withTenant(organizationId, (tx) =>
tx.insert(projects).values({ organizationId, name: input.name })
);
// Données propres à l'organisation, non mises en cache : on rafraîchit la page.
refresh();
}
Le même schéma Zod sert à la validation côté client dans le formulaire (avec React Hook Form par exemple) : une seule définition, deux usages, et des messages d'erreur cohérents.
Authentification, organisations et rôles
Le modèle de données de base d'un SaaS B2B tient en quatre tables : users, organizations, memberships (utilisateur, organisation, rôle) et invitations. Commencez avec trois rôles (owner, admin, member) et une fonction qui répond à « ce membre peut-il faire cette action ? ». Les permissions fines viendront quand un client les demandera.
La règle la plus importante : le fichier proxy.ts (anciennement middleware.ts) ne doit jamais être votre seule vérification. En mars 2025, la faille CVE-2025-29927 permettait de contourner complètement le middleware de Next.js avec un simple en-tête HTTP. Les applications qui vérifiaient les droits uniquement à cet endroit étaient grandes ouvertes ; celles qui vérifiaient aussi dans la couche d'accès aux données ne l'étaient pas.
proxy.ts reste utile pour une redirection rapide et optimiste :
// proxy.ts : redirige tôt, ne protège rien à lui seul
import { NextResponse, type NextRequest } from "next/server";
export default function proxy(request: NextRequest) {
if (!request.cookies.has("session")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*"] };
La vraie vérification (session valide, appartenance, rôle) se fait dans requireMembership, appelée par chaque fonction de data.ts et chaque Server Action.
Facturation : webhooks Stripe idempotents et table de droits
Deux principes évitent la plupart des incidents de facturation.
Premier principe : l'état de l'abonnement vient des webhooks, pas de la page de retour du paiement. L'utilisateur peut fermer l'onglet avant la redirection ; le webhook, lui, arrive. Stripe peut aussi envoyer un même événement plusieurs fois, et pas forcément dans l'ordre. Le handler doit donc vérifier la signature et ignorer les doublons :
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { db } from "@/db/client";
import { stripeEvents } from "@/db/schema";
import { applyBillingEvent } from "@/modules/billing/apply-event";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
// Corps brut : la signature porte sur les octets exacts reçus.
const payload = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
await db.transaction(async (tx) => {
const inserted = await tx
.insert(stripeEvents)
.values({ id: event.id, type: event.type })
.onConflictDoNothing()
.returning({ id: stripeEvents.id });
if (inserted.length === 0) return; // déjà traité
await applyBillingEvent(tx, event);
});
return new Response(null, { status: 200 });
}
Le webhook n'a pas de session utilisateur : applyBillingEvent retrouve l'organisation à partir du customer Stripe, puis fixe app.organization_id dans la transaction avant d'écrire, comme withTenant.
Second principe : le code teste des droits, pas des plans. Au lieu de if (plan === "pro") dispersé dans vingt fichiers, une table entitlements (ou une constante typée) associe chaque plan à ses fonctionnalités et à ses limites. Le jour où le marketing crée un plan « Business », vous modifiez une ligne de configuration, pas vingt conditions.
Tâches de fond et pattern outbox
Envoyer un e-mail, générer un PDF, appeler une API tierce ou un modèle d'IA : tout ce qui est lent ou peut échouer sort de la requête HTTP. Pour les petites tâches sans enjeu, after() de Next.js exécute du code après l'envoi de la réponse. Dès qu'une tâche doit être reprise en cas d'échec, il faut une vraie file d'attente.
Le piège est d'écrire en base puis d'envoyer un message à la file : si le processus meurt entre les deux, l'événement est perdu. Le pattern outbox règle ce problème en écrivant l'événement dans la même transaction que la donnée métier :
create table outbox (
id bigint generated always as identity primary key,
organization_id uuid not null,
topic text not null,
payload jsonb not null,
created_at timestamptz not null default now(),
processed_at timestamptz
);
-- Un worker prend un lot dans une transaction ; les autres workers
-- sautent les lignes verrouillées au lieu de les attendre.
select id, topic, payload
from outbox
where processed_at is null
order by id
limit 20
for update skip locked;
Le worker traite le lot, marque processed_at, puis valide la transaction. Comme un message peut être traité deux fois après un crash, chaque handler doit être idempotent. Des bibliothèques comme pg-boss implémentent ce mécanisme sur PostgreSQL ; Inngest ou Trigger.dev l'externalisent avec des reprises et un tableau de bord.
Performance : ce qui compte vraiment
Dans un SaaS, les lenteurs viennent rarement de React. Elles viennent de la base et du réseau. Par ordre d'impact :
- Le pooling des connexions. Des fonctions serverless qui ouvrent chacune leur connexion saturent PostgreSQL en quelques secondes de pic. Passez par un pooler en mode transaction. Selon la version du pooler, il faudra peut-être désactiver les requêtes préparées côté driver.
- Des index qui suivent les requêtes. Presque toutes les requêtes filtrent par
organization_id: la plupart des index composites commencent par cette colonne. Vérifiez avecEXPLAIN (ANALYZE, BUFFERS)plutôt qu'à l'intuition. - Pas de N+1. Une liste de 50 projets qui déclenche 50 requêtes pour leurs auteurs se corrige avec une jointure ou les requêtes relationnelles de Drizzle.
- La pagination par curseur (
where created_at < $1 order by created_at desc limit 50) plutôt queoffset, qui ralentit à mesure qu'on avance dans les pages. - Pas de cascades de chargement. Lancez en parallèle (
Promise.all) les lectures indépendantes et isolez les blocs lents dans un<Suspense>pour que le reste de la page s'affiche aussitôt. - Le cache, en dernier. Avec
cacheComponents: true, Next.js 16 ne met rien en cache implicitement. Réservez"use cache"etcacheLifeaux données partagées entre clients (grille tarifaire, documentation, pages publiques) et invalidez-les avecupdateTag()après une mutation. Les données d'une organisation restent dynamiques : c'est plus simple et plus sûr.
Observabilité, tests et déploiement
Observabilité. Un fichier instrumentation.ts à la racine suffit à envoyer des traces OpenTelemetry :
// instrumentation.ts
import { registerOTel } from "@vercel/otel";
export function register() {
registerOTel({ serviceName: "saas-app" });
}
Ajoutez l'identifiant d'organisation aux traces et aux logs : quand un client signale une lenteur, vous filtrez sur son organisation au lieu de chercher dans le flot de toutes les requêtes. Sentry (ou un équivalent) capture les erreurs avec leur contexte.
Tests. Je répartis l'effort ainsi :
- des tests unitaires (Vitest) sur la logique métier pure : calcul de droits, règles de facturation ;
- des tests d'intégration contre un vrai PostgreSQL (Testcontainers ou une base par job de CI), dont au moins un test qui se connecte avec l'organisation A et vérifie qu'il ne voit aucune ligne de l'organisation B ;
- quelques tests de bout en bout (Playwright) sur les parcours qui rapportent de l'argent : inscription, invitation, paiement.
Déploiement. Les migrations sont générées (drizzle-kit generate), relues en pull request puis appliquées par la CI avant le déploiement. Pour changer un schéma sans interruption, procédez en deux temps (expand/contract) : ajoutez la nouvelle colonne, déployez le code qui écrit dans les deux, migrez les données, puis seulement retirez l'ancienne. Les environnements de prévisualisation par branche, avec une branche de base de données dédiée (Neon le propose nativement), permettent de tester une migration avant de la fusionner.
Les erreurs qui coûtent cher
- Découper en microservices dès le départ. Vous payez la complexité distribuée (réseau, transactions, déploiements) avant d'en avoir le besoin.
- Vérifier les droits uniquement dans
proxy.ts. Voir plus haut : une faille dans cette couche, et tout est ouvert. - Se connecter en propriétaire des tables. La RLS ne s'applique pas au propriétaire sans
FORCE ROW LEVEL SECURITY, ni aux rôlesBYPASSRLS. - Coder les plans en dur. Chaque changement commercial devient un chantier technique.
- Des webhooks non idempotents. Un événement rejoué crédite deux fois, ou annule un abonnement déjà réactivé.
- Du travail lent dans la requête. Un e-mail qui expire bloque l'inscription de l'utilisateur.
- Aucune trace corrélée. Sans identifiant d'organisation dans les logs, chaque incident commence par une heure de recherche.
Checklist avant la mise en production
- Chaque table métier a une colonne
organization_id, un index qui commence par elle et une politique RLS avecFORCE. - L'application se connecte avec un rôle sans
BYPASSRLS; les migrations utilisent un autre rôle. - Chaque lecture et chaque Server Action vérifie la session, l'appartenance et le rôle.
- Les entrées sont validées par Zod côté serveur, même si le formulaire les valide déjà.
- Les webhooks vérifient leur signature et ignorent les événements déjà traités.
- Les e-mails, exports et appels externes passent par une file d'attente avec reprise.
- La base est derrière un pooler, et les requêtes principales ont été vérifiées avec
EXPLAIN ANALYZE. - Les traces OpenTelemetry et les erreurs portent l'identifiant d'organisation.
- Un test automatisé prouve qu'une organisation ne voit pas les données d'une autre.
- Les sauvegardes sont testées par une vraie restauration, pas seulement configurées.
Questions fréquentes
Next.js est-il adapté à un SaaS B2B ?
Oui. Next.js gère dans le même projet les pages publiques indexables (accueil, tarifs, documentation), l'application authentifiée et les API (webhooks, intégrations). Pour une application entièrement privée, sans SEO ni rendu serveur, une SPA avec Vite reste une alternative plus légère.
Drizzle ou Prisma pour un SaaS avec PostgreSQL ?
Les deux conviennent. Drizzle reste proche du SQL, ce qui facilite la Row Level Security, les requêtes avancées et le contrôle des transactions. Prisma offre un outillage très complet et un schéma déclaratif apprécié des équipes. Le choix compte moins que la discipline : une couche d'accès aux données unique et des migrations relues.
Faut-il des microservices pour qu'un SaaS soit scalable ?
Non. Un monolithe modulaire avec une base PostgreSQL bien indexée et des tâches de fond supporte une charge élevée. On extrait un service quand un module a des besoins vraiment différents (charge, langage, équipe dédiée), et des frontières de modules propres rendent cette extraction simple.
Comment isoler les données de chaque client dans PostgreSQL ?
Dans la plupart des cas : une base partagée, une colonne organization_id dans chaque table métier, et des politiques de Row Level Security qui comparent cette colonne à une variable fixée au début de chaque transaction avec set_config(..., true). Un schéma ou une base par client se justifient pour des exigences contractuelles fortes.
Server Actions ou API REST pour les mutations ?
Les Server Actions pour les mutations déclenchées par votre propre interface, les Route Handlers pour tout ce qui est appelé de l'extérieur (webhooks, API publique, mobile). Dans les deux cas, il faut valider les entrées et vérifier les droits : une Server Action est un endpoint HTTP public.
Pour aller plus loin
Si vous préparez un SaaS ou une application web avec cette stack, je peux intervenir en freelance, du cadrage à la mise en production : voir mes services de développement React, Next.js et TypeScript. Si vous recrutez, la section React et Node.js de ma page recrutement détaille ce que j'apporte à une équipe produit, et l'étude de cas OpsPilot montre un projet structuré autour de PostgreSQL, Redis et de l'observabilité.
À lire aussi : la checklist SEO d'un portfolio Next.js, l'architecture d'un agent IA en production pour intégrer de l'IA dans un SaaS sans perdre le contrôle, et le parcours Next.js, React et TypeScript.
