Apogée Consult
Retour au blog
Mathieu Ponton
Mathieu PontonCo-Founder & ingénieur logiciel

Je suis Mathieu Ponton, Co-Founder & ingénieur logiciel chez Apogée Consult à Lyon. Ingénieur diplômé de Polytech Lyon (Informatique), j'ai fait trois ans en apprentissage, partagés entre la Métropole de Lyon (inclusion numérique avec Res'in et sobriété énergétique avec Écolyo) et Superwyze, une startup medtech (POCs, dont certains aujourd'hui industrialisés, et travail sur des codebases existantes). J'ai livré plus de 10 projets en production (web, mobile et IA / RAG) pour des PME, startups et organisations publiques.

Architecture d'un SaaS multi-tenant en Next.js + Postgres : nos choix et leurs limites

  • archi

Isolation par schéma, par base, ou par colonne tenant_id : trois modèles aux compromis radicalement différents. Voici ce que nous avons implémenté, pourquoi, et ce qu'on ferait différemment.

Architecture d'un SaaS multi-tenant en Next.js + Postgres : nos choix et leurs limites

Le multi-tenant n'est pas une feature. C'est une contrainte structurelle qui touche la base de données, le routage, l'authentification, et le modèle de données. Si on la traite comme une feature, on paie la dette plus tard, au pire moment, lors du premier incident de fuite de données entre tenants.

Voici ce que nous avons construit sur 18 mois, les erreurs qu'on a faites, et les limites qu'on vit encore aujourd'hui.

Les trois stratégies d'isolation

Avant les choix d'implémentation, il faut choisir un modèle d'isolation. Les trois options ont des profils de compromis radicalement différents.

Isolation par base de données

Chaque tenant a sa propre base Postgres. Isolation maximale, migrations indépendantes, sauvegarde par tenant. En contrepartie : coût opérationnel élevé, connexions multiplexées difficiles à gérer, et la limite des connexions Postgres devient rapidement un problème si on scale en nombre de tenants.

Nous écartons ce modèle pour les SaaS B2B à plus de 50 tenants sans négociation de contrat entreprise.

Isolation par schéma Postgres

Chaque tenant a son propre schéma dans la même base. search_path positionné par connexion. Les tables sont identiques, la migration touche tous les schémas.

-- Création d'un tenant
CREATE SCHEMA tenant_abc123;
SET search_path TO tenant_abc123, public;
CREATE TABLE users (LIKE public.users INCLUDING ALL);

Plus léger que des bases séparées, mais la gestion des migrations sur N schémas est complexe. Si on a 500 tenants et qu'une migration prend 30 secondes par schéma, on parle de 4 heures de migration. Certains outils (Prisma notamment, avant la v6) n'ont pas de support natif multi-schéma.

Isolation par colonne (shared schema)

Toutes les tables partagent une colonne tenant_id. C'est le modèle le plus simple à démarrer et le plus risqué si mal sécurisé.

CREATE TABLE projects (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL REFERENCES tenants(id),
  name        text NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX ON projects (tenant_id);

C'est le modèle que nous utilisons. La simplicité opérationnelle l'emporte, à condition de sécuriser l'isolation au niveau de la base avec Row Level Security.

Row Level Security : le filet de sécurité indispensable

Sans RLS, l'isolation tenant_id repose entièrement sur l'application. Une requête sans filtre WHERE retourne les données de tous les tenants. C'est une bombe à retardement.

-- Activation de RLS sur chaque table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- Policy : un tenant ne voit que ses propres lignes
CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- On crée un rôle applicatif sans superpouvoirs
CREATE ROLE app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;

Côté application, chaque connexion doit setter la variable de session avant d'exécuter des requêtes :

// Middleware Drizzle, exécuté sur chaque requête
async function withTenant<T>(
  tenantId: string,
  fn: (db: DrizzleClient) => Promise<T>
): Promise<T> {
  return db.transaction(async (tx) => {
    await tx.execute(
      sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`
    );
    return fn(tx);
  });
}

Le true en troisième argument de set_config limite la variable à la transaction courante, ce qui est plus sûr que la durée de la connexion dans un contexte de connection pooling.

Attention : RLS est désactivé pour les superusers Postgres. Les migrations et les scripts admin doivent tourner avec un rôle non-superuser ou contourner RLS explicitement avec SET LOCAL row_security = off.

Routage multi-tenant dans Next.js

Deux approches coexistent : le routage par sous-domaine (tenant.app.com) et le routage par path (app.com/org/tenant).

Nous utilisons les sous-domaines. Le middleware Next.js intercepte chaque requête et identifie le tenant depuis le hostname :

// middleware.ts
export async function middleware(req: NextRequest) {
  const host = req.headers.get("host") ?? "";
  const subdomain = host.split(".")[0];

  // Exclure les sous-domaines système
  if (["www", "app", "api"].includes(subdomain)) {
    return NextResponse.next();
  }

  // Résolution du tenant depuis le subdomain
  const tenant = await resolveTenant(subdomain);
  if (!tenant) {
    return NextResponse.redirect(new URL("/not-found", req.url));
  }

  // Propagation via header pour les Server Components
  const response = NextResponse.next();
  response.headers.set("x-tenant-id", tenant.id);
  response.headers.set("x-tenant-slug", tenant.slug);
  return response;
}

Côté Server Components, on lit le tenant depuis les headers :

import { headers } from "next/headers";

async function getTenantContext() {
  const h = await headers();
  const tenantId = h.get("x-tenant-id");
  if (!tenantId) throw new Error("No tenant context");
  return { tenantId };
}

Ce pattern a une limite importante : le middleware tourne sur l'Edge Runtime, sans accès direct à Postgres. La résolution du tenant doit passer par un cache (nous utilisons Redis avec un TTL de 5 minutes) ou une API Route.

Le problème du wildcard DNS et des certificats

Routage par sous-domaine implique un wildcard DNS (*.app.com) et un certificat wildcard. Sur Vercel, c'est natif. En self-hosting, c'est Traefik ou Nginx avec Let's Encrypt et le challenge DNS-01.

Nous avons eu un incident en production où Let's Encrypt a atteint sa limite de 50 certificats par domaine racine par semaine. Depuis, nous utilisons un certificat wildcard unique renouvelé trimestriellement plutôt que des certificats par sous-domaine.

L'authentification cross-tenant

NextAuth (Auth.js) stocke les sessions avec un userId. Nous avons ajouté le tenantId dans le token JWT et dans la session :

callbacks: {
  async jwt({ token, user }) {
    if (user) {
      token.tenantId = user.tenantId;
      token.tenantSlug = user.tenantSlug;
    }
    return token;
  },
  async session({ session, token }) {
    session.tenantId = token.tenantId as string;
    return session;
  },
}

Un utilisateur peut appartenir à plusieurs tenants (cas fréquent en B2B). Nous gérons ça avec une table de relation tenant_memberships et un mécanisme de switch de tenant qui recrée la session.

Les limites qu'on vit encore

Les migrations sur les tables volumineuses sont risquées

RLS n'évite pas le problème classique des migrations Postgres sur des tables à plusieurs millions de lignes. Un ALTER TABLE ADD COLUMN NOT NULL pose un verrou. Nous utilisons pg-osc (Online Schema Change) pour les migrations zero-downtime, mais l'outillage est encore jeune.

Les analytics cross-tenant sont complexes

Toute requête analytique qui agrège des données de plusieurs tenants (tableaux de bord admin, métriques globales) doit contourner RLS. On le fait avec un rôle dédié sans policy, mais ça crée un point de friction. Chaque requête admin doit être explicitement revue pour s'assurer qu'elle ne fuit pas de données entre tenants.

Le plan Postgres peut ignorer le tenant_id

Sur une table avec 10 millions de lignes et 5000 tenants, un tenant avec 2000 lignes est statistiquement petit. Le planner Postgres peut choisir un sequential scan au lieu d'utiliser l'index sur tenant_id si les statistiques sont désalignées. Nous avons ajouté un VACUUM ANALYZE hebdomadaire et surveillons les plans avec pg_stat_statements.

Le multi-tenant shared schema avec RLS est un choix raisonnable pour un SaaS B2B de taille intermédiaire. Il devient problématique dès que des clients exigent contractuellement une isolation de données physique, auquel cas l'isolation par base reste la seule réponse honnête. Où tracez-vous cette ligne dans vos contrats ?

Disponible pour de nouveaux projets

Un projet à concrétiser ?
Parlons-en, sans engagement.

Un échange de 30 minutes pour cadrer votre besoin, qualifier la faisabilité et vous proposer une trajectoire claire.

1// kick-off : réponse sous 24h
2const project = await apogee.scope({
3 type: 'web | mobile | IA',
4 timeline: '6 à 16 semaines',
5 approach: 'sur-mesure'
6})
7// → cadrage offert
SaaS multi-tenant Next.js + Postgres : architecture | Apogée Consult