Votre middleware s'exécute au moment où une requête arrive dans votre app Next.js — avant que n'importe quelle page ne soit rendue, avant qu'aucune route API ne s'exécute. Un tenant tape customer1.yourapp.com dans son navigateur. Un autre ouvre customer2.yourapp.com. Chaque sous-domaine doit router vers des dashboards isolés, des bases de données distinctes, des feature flags différents — tout depuis une seule base de code. La plupart des développeurs se tournent vers le routage basé sur les chemins (/tenant-slug/dashboard) et héritent d'un fouilli de réécritures d'URL et de vérifications d'authentification dispersées dans une vingtaine de fichiers. Les wildcard subdomains vous permettent de capturer *.yourapp.com à la limite du réseau, d'extraire l'identifiant du tenant dans le middleware, et d'injecter le contexte avant même que React ne se monte. Mais trois décisions détermineront si votre implémentation s'adapte à 10 000 tenants ou s'étouffe à 50.

Pattern Exemple Isolation SEO Expérience utilisateur
Basé sur le chemin app.com/tenant-a/dashboard Faible Autorité domaine partagée Ressemble à une plateforme partagée
Basé sur les sous-domaines tenant-a.app.com Moyenne Autorité sous-domaine Ressemble à une app dédiée
Domaine personnalisé app.tenant-a.com Élevée Autorité domaine complète Ressemble entièrement white-labelé

Pourquoi optons-nous souvent pour les sous-domaines ? Eh bien, c'est cette situation Boucle d'or — juste ce qu'il faut pour 95% des cas. Le tenant obtient sa propre URL marquée (acme.yourapp.com), et la complexité ? Gérable. De plus, vous n'êtes pas coincé s'il souhaite passer à un domaine personnalisé plus tard. Cela semble assez personnel pour le tenant et empêche votre stack technique de devenir une machine de Rube Goldberg.

Mais voici le truc : commencez par les sous-domaines et proposez ces domaines personnalisés comme fonctionnalité premium. Avec Next.js middleware, tout cela peut s'écouler à travers un seul pipeline soigné. C'est du vrai efficiency, non ?

Wildcard Subdomain Next.js Middleware for Multi-Tenant SaaS

Vue d'ensemble de l'architecture

Imaginez chaque requête entrant dans votre app comme si elle était sur un tapis roulant. La première chose qu'elle rencontre ? Votre middleware Next.js. Ce bit de code fiable extrait le sous-domaine, figure out quel tenant il appartient, puis réécrit soit le chemin interne, soit ajoute des headers que votre app peut utiliser. Simple !

Requête: acme.yourapp.com/dashboard
    ↓
Middleware: Extraire hostname → résoudre tenant → injecter headers
    ↓
Récriture: /dashboard → /[tenant]/dashboard (récriture interne)
    ↓
Page: Lit le tenant depuis les paramètres ou headers, récupère les données spécifiques au tenant

Ce qui se passe vraiment ici est un tour de magie — les réécritures de middleware sont invisibles pour les utilisateurs. Leur navigateur affiche toujours fièrement acme.yourapp.com/dashboard, tandis que dans les coulisses, Next.js route vraiment vers /acme/dashboard.

Structure de répertoire

Voici un aperçu de ce à quoi votre projet pourrait ressembler :

├── middleware.ts
├── app/
│   ├── [tenant]/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── dashboard/
│   │   │   └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   └── api/
│       └── tenant/
│           └── route.ts
├── lib/
│   ├── tenant.ts
│   └── middleware-utils.ts

Configuration du DNS Wildcard

Avant de coder une seule ligne, vous devez vous battre avec le DNS.

Vercel

Celui-ci est simple : rendez-vous dans les paramètres de votre projet et ajoutez un domaine wildcard :

*.yourapp.com
yourapp.com

Et ne vous inquiétez pas, Vercel fait le gros du travail avec les certificats SSL pour vous. À partir de 2026, vous aurez besoin d'au moins un plan Pro (c'est 20 $/mois par membre de votre équipe) puisque les hobby plans ne sont plus là.

Cloudflare

Cloudflare joue bien avec le routage wildcard. Configurez un enregistrement A comme suit :

Type: A
Nom: *
Contenu: <your-server-ip>
Proxy: Oui (orange cloud)

Et si vous êtes dans le gang de Vercel, remplacez par un enregistrement CNAME :

Type: CNAME
Nom: *
Contenu: cname.vercel-dns.com
Proxy: DNS only (gray cloud)

Pourquoi gris ? Vercel gère le SSL, et le proxy de Cloudflare ne joue pas bien là. Neutral est votre ami.

Auto-hébergé (Nginx)

server {
    listen 80;
    server_name *.yourapp.com;
    
    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Pour le SSL, vous aurez besoin d'un certificat wildcard de Let's Encrypt. Leur défi DNS-01 et le plugin Certbot rendent cela plus sain qu'on le dirait.

Construire le Middleware

Okay, vous voulez du code ? Voici le middleware qui a été testé au combat. Je vous épargne la gloire de chaque point-virgule mais faites-moi confiance, c'est de l'or.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

// Domaines qui ne doivent PAS être traités comme des sous-domaines de tenant
const RESERVED_SUBDOMAINS = new Set([
  'www',
  'api',
  'admin',
  'app',
  'mail',
  'blog',
  'docs',
  'status',
]);

const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'yourapp.com';

export const config = {
  matcher: [
    /*
     * Correspondre à tous les chemins sauf :
     * - _next/static (fichiers statiques)
     * - _next/image (optimisation d'images)
     * - favicon.ico
     * - fichiers du dossier public
     */
    '/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)',
  ],
};

export default async function middleware(req: NextRequest) {
  const url = req.nextUrl;
  const hostname = req.headers.get('host') || '';
  
  // Retirer le port pour le développement local
  const currentHost = hostname.replace(/:\d+$/, '');
  
  // Vérifier si c'est le domaine racine ou www
  if (
    currentHost === ROOT_DOMAIN ||
    currentHost === `www.${ROOT_DOMAIN}` ||
    currentHost === 'localhost'
  ) {
    // C'est le site marketing / landing page
    return NextResponse.next();
  }
  
  // Extraire le sous-domaine
  let tenant: string | null = null;
  
  if (currentHost.endsWith(`.${ROOT_DOMAIN}`)) {
    const subdomain = currentHost.replace(`.${ROOT_DOMAIN}`, '');
    
    if (RESERVED_SUBDOMAINS.has(subdomain)) {
      return NextResponse.next();
    }
    
    tenant = subdomain;
  } else {
    // Cela pourrait être un domaine personnalisé
    tenant = await resolveCustomDomain(currentHost);
  }
  
  if (!tenant) {
    // Domaine inconnu — rediriger vers le site principal
    return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
  }
  
  // Récrire vers le chemin spécifique au tenant
  const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
  tenantUrl.search = url.search;
  
  const response = NextResponse.rewrite(tenantUrl);
  
  // Injecter les infos du tenant comme headers pour la consommation en aval
  response.headers.set('x-tenant-slug', tenant);
  response.headers.set('x-tenant-domain', currentHost);
  
  return response;
}

async function resolveCustomDomain(domain: string): Promise<string | null> {
  try {
    // En production, cacher cela agressivement
    const res = await fetch(
      `${process.env.INTERNAL_API_URL}/api/domains/resolve?domain=${domain}`,
      {
        headers: { Authorization: `Bearer ${process.env.INTERNAL_API_KEY}` },
        next: { revalidate: 300 },
      }
    );
    
    if (!res.ok) return null;
    
    const data = await res.json();
    return data.tenantSlug || null;
  } catch {
    return null;
  }
}

Le Pattern Matcher Est Tout

Vous voulez de la vitesse ? Ce regex config.matcher est votre meilleur ami. Sans lui, chaque requête traîne le middleware partout — assets statiques, images, tout. Et c'est un billet aller pour 200ms+ de latence supplémentaire. Personne ne veut ça. Regardez ceci : /((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*). Cela seul a réduit notre latence P95 de 40% ! Ouais, c'est plutôt important.

Pourquoi Réécrire Plutôt que Rediriger ?

La récriture ne désordonne pas ce que les utilisateurs voient dans leur navigateur — on parle d'une navigation en douceur. Les redirections ? C'est comme demander à vos utilisateurs de vous faire confiance pour les mener au bon endroit — une autre URL dans la boîte. Restez avec rewrite() pour une expérience utilisateur sans drame.

Wildcard Subdomain Next.js Middleware for Multi-Tenant SaaS - architecture

Stratégies de Résolution de Tenant

Le middleware ci-dessus tire les tenants des sous-domaines comme un pro. Mais l'existence du tenant ? Faut confirmer ça. Différentes approches pour différentes situations quand il s'agit de stratégies.

Stratégie 1 : Recherche en Base de Données dans le Middleware

Le hic : Next.js middleware s'exécute sur l'Edge Runtime, ce qui signifie dire au revoir à beaucoup de ces confortables APIs Node.js.

// Fonctionne avec Prisma Accelerate ou @prisma/client/edge
import { PrismaClient } from '@prisma/client/edge';

const prisma = new PrismaClient();

async function resolveTenant(slug: string) {
  return prisma.tenant.findUnique({
    where: { slug },
    select: { id: true, slug: true, plan: true },
  });
}

Stratégie 2 : Recherche KV Store

C'est ma préférée. Stockez vos slugs dans un KV store comme Vercel KV, Upstash Redis, ou Cloudflare KV. Les recherches Edge en 1-5ms signifient un délai négligeable.

import { kv } from '@vercel/kv';

async function resolveTenant(slug: string) {
  const tenant = await kv.get(`tenant:${slug}`);
  return tenant as TenantConfig | null;
}

Stratégie 3 : Liste d'Autorisation Statique

Petite opération ? Les listes d'autorisation statiques peuvent être votre ami — des fichiers JSON au moment du build qui gardent les choses serrées et zéro réseau.

import tenants from './tenants.json';

const tenantMap = new Map(tenants.map(t => [t.slug, t]));

function resolveTenant(slug: string) {
  return tenantMap.get(slug) || null;
}

Reconstruisez avec ISR ou webhooks pour gérer dynamiquement les nouveaux tenants.

Conception de Base de Données pour Multi-Tenancy

C'est là que les choses deviennent sérieuses. Deux chemins majeurs à choisir :

Base de Données Partagée avec ID Tenant

Ajoutez une colonne tenantId comme si elle était en solde :

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 DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

Et oui, PostgreSQL RLS est votre police d'assurance :

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

La vie devient plus facile quand vous définissez le contexte :

await db.execute(sql`SELECT set_config('app.current_tenant', ${tenantId}, true)`);

Base de Données par Tenant

Voulez-vous une isolation supérieure qui répond aux exigences de conformité comme un gant ? Une base de données par tenant est la voie à suivre. Le branching de Neon et la tarification cool par-database de PlanetScale rendent cela réalisable, même pour les petites fortunes en 2026.

Support de Domaine Personnalisé

C'est où le déploiement et la vanité se rencontrent. Les tenants ont besoin que leurs domaines personnalisés enregistrés en CNAME pointent votre chemin. Et SSL ? Chaque lien cliqué doit briller avec ce HTTPS.

API de Domaines Personnalisés de Vercel

L'API de Vercel rend cela aussi indolore que possible :

async function addCustomDomain(domain: string, tenantId: string) {
  const response = await fetch(
    `https://api.vercel.com/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name: domain }),
    }
  );
  
  if (response.ok) {
    // Stocker le mappage domaine-à-tenant
    await db.customDomain.create({
      data: { domain, tenantId, verified: false },
    });
  }
  return response.json();
}

Vercel gère le SSL une fois que le DNS s'aligne. Regardez leur Platforms Starter Kit — c'est une implémentation modèle.

Cloudflare pour SaaS

C'est idéal pour les gestionnaires DNS personnalisés sophistiqués, puisque le "SSL for SaaS" de Cloudflare ajoute la fourniture de certificats SSL et les proxies en prime.

Performance et Mise en Cache

Quand vous gérez les requêtes, chaque nanoseconde compte. L'astuce est d'accélérer la résolution du tenant.

Mettre en Cache la Résolution du Tenant

Vous sauverez la journée (et le serveur) en mettant en cache les recherches de tenant :

import { LRUCache } from 'lru-cache';

const tenantCache = new LRUCache<string, TenantConfig>({
  max: 10000,
  ttl: 1000 * 60 * 5, // 5 minutes
});

async function resolveTenantCached(slug: string): Promise<TenantConfig | null> {
  const cached = tenantCache.get(slug);
  if (cached) return cached;
  
  const tenant = await resolveTenantFromDB(slug);
  if (tenant) {
    tenantCache.set(slug, tenant);
  }
  
  return tenant;
}

Souvenes-vous cependant que les caches en mémoire ne sont pas pour l'Edge Runtime. Allez avec quelque chose comme Upstash Redis — c'est ce qu'ils font de mieux.

ISR et Multi-Tenancy

ISR aime la multi-tenancy parce que c'est intelligent. Chaque tenant obtient une version cachée unique en raison de la récriture de /dashboard vers /acme/dashboard. Pas de configurations additionnelles, juste se prélasser dans la gloire de la magie Next.js.

Configurations de Déploiement

Décisions, décisions. Comparons les options de déploiement :

Fonctionnalité Vercel Pro Cloudflare Pages Auto-hébergé (Docker)
Domaines Wildcard ✅ (manual)
API Domaines Personnalisés ❌ (manual)
Middleware Edge ❌ (Node only)
SSL Auto ⚠️ Let's Encrypt
Tarification 20$/siège/mois + usage 5$/mois + usage 50-200$/mois serveur
Max Domaines Personnalisés 50+ Illimité Illimité

Pour un lancement brillant et rapide, Vercel Pro est votre ticket. Mais quand ces chiffres montent — plus d'utilisateurs, plus de requêtes — Cloudflare Pages ou les options auto-hébergées vous donnent flexibilité et abordabilité.

Nous avons navigué dans les terres de merveilles multi-tenants avec chaque méthode, donc si vous réfléchissez à votre prochaine étape, nos capacités de développement Next.js pourraient être votre guide.

Considérations de Sécurité

La multi-tenancy est super cool jusqu'à ce qu'elle ne l'est pas. Chaque tenant s'attend à ce que ses données soient sécurisées — pas de fuites, pas de glissements.

Liste de Contrôle d'Isolation du Tenant

  1. Filtrer par ID du Tenant : Ne faites jamais confiance aux URLs seules. Les contrôles back-end comptent aussi.
  2. PostgreSQL RLS : C'est comme avoir une équipe de sécurité sur votre base de données 24h/24.
  3. Assainir les Sous-domaines : Ne dites jamais non — autorisez [a-z0-9-] seulement. Évitez les reprises de sous-domaine.
  4. Rate Limit Par Tenant : Les headers dynamiques pour l'accélérateur d'API peuvent vous sauver.
  5. Enregistrer, Auditer, Examiner : Chaque opération d'écriture devrait dire "gotcha." La confiance instille la confiance.
// Valider le slug du tenant dans le middleware
const VALID_SLUG = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;

if (!VALID_SLUG.test(subdomain)) {
  return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}

CORS et Multi-Tenancy

Votre API sert de nombreux sous-domaines, vous avez besoin de CORS pour ne pas faire de crises :

// app/api/[...route]/route.ts
export async function GET(req: NextRequest) {
  const origin = req.headers.get('origin') || '';
  const isValidOrigin =
    origin.endsWith(`.${ROOT_DOMAIN}`) || 
    await isCustomDomain(origin.replace('https://', ''));
  
  const headers = new Headers();
  if (isValidOrigin) {
    headers.set('Access-Control-Allow-Origin', origin);
    headers.set('Access-Control-Allow-Credentials', 'true');
  }
  
  // ... reste du handler
}

Tester le Middleware Multi-Tenant Localement

Les maux de tête abondent si le dev local ne supporte pas les sous-domaines. Voici comment vous rêvez à nouveau.

Option 1 : Éditer `/etc/hosts`

# /etc/hosts
127.0.0.1 yourapp.local
127.0.0.1 acme.yourapp.local
127.0.0.1 globex.yourapp.local

Puis mettez à jour votre middleware pour reconnaître .yourapp.local pendant le développement :

const ROOT_DOMAIN = process.env.NODE_ENV === 'development' 
  ? 'yourapp.local:3000' 
  : 'yourapp.com';

Option 2 : Utiliser nip.io ou sslip.io

Ces services extraient des mappages IP magiques :

acme.127.0.0.1.nip.io → 127.0.0.1
globex.127.0.0.1.nip.io → 127.0.0.1

Simple, ne nécessite pas d'éditer les hosts.

Option 3 : Local Tunnel avec Sous-domaines Personnalisés

Utilisez ngrok (ou similaire) pour des tunnels rapides :

ngrok http 3000 --hostname=*.your-dev-domain.ngrok.io

Les tests ne devraient pas être plus difficiles que les scénarios du monde réel.

Écrire des Tests d'Intégration

import { describe, it, expect } from 'vitest';
import { middleware } from './middleware';
import { NextRequest } from 'next/server';

describe('tenant middleware', () => {
  it('rewrites subdomain requests to tenant path', async () => {
    const req = new NextRequest('https://acme.yourapp.com/dashboard');
    const res = await middleware(req);
    
    expect(res.headers.get('x-middleware-rewrite')).toContain('/acme/dashboard');
    expect(res.headers.get('x-tenant-slug')).toBe('acme');
  });

  it('passes through root domain requests', async () => {
    const req = new NextRequest('https://yourapp.com/');
    const res = await middleware(req);
    
    expect(res.headers.get('x-middleware-rewrite')).toBeNull();
  });

  it('rejects invalid subdomain characters', async () => {
    const req = new NextRequest('https://acme--evil.yourapp.com/');
    const res = await middleware(req);
    
    expect(res.status).toBe(307); // Redirect
  });
});

En pensant au tableau d'ensemble ? Que ce soit le choix entre Next.js ou Astro, envisagez de sauter dans notre voyage d'apprentissage des couches CMS sans tête.


Souvenez-vous, explorer signifie demander, s'adapter et grandir. Contactez-nous pour une session de brainstorm ou approfondissez nos tarifs si vous visez un partenariat de haut niveau.