Middleware Next.js pour Sous-domaines Wildcard dans SaaS Multi-Locataire
Pourquoi la Multi-Locatairité Basée sur Sous-domaines
Décomposons cela. Vous avez généralement trois options quand il s'agit de routage multi-locataire dans les applications SaaS :
| Pattern | Exemple | Isolation | SEO | Expérience Utilisateur |
|---|---|---|---|---|
| Basée sur le chemin | app.com/tenant-a/dashboard |
Faible | Autorité de domaine partagée | Ressemble à une plateforme partagée |
| Basée sur le sous-domaine | tenant-a.app.com |
Moyen | Autorité de sous-domaine | Ressemble à une app dédiée |
| Domaine personnalisé | app.tenant-a.com |
Élevée | Autorité de 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 parfait pour 95% des cas. Le locataire obtient sa propre URL de marque (acme.votreapp.com), et la complexité ? Gérable. De plus, vous n'êtes pas bloqué s'il veut passer à un domaine personnalisé plus tard. C'est assez personnel pour le locataire et cela empêche votre stack technologique de devenir une machine de Rube Goldberg.
Mais voilà le truc : commencez avec les sous-domaines et proposez ces domaines personnalisés comme feature premium. Avec Next.js middleware, tout cela peut circuler via un seul pipeline élégant. Parlons d'efficacité, n'est-ce pas ?

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 code fiable extrait le sous-domaine, détermine quel locataire il appartient, puis réécrit soit le chemin interne, soit signale cette requête avec des en-têtes que votre app peut utiliser. C'est simple !
Requête : acme.votreapp.com/dashboard
↓
Middleware : Extraire hostname → résoudre locataire → injecter en-têtes
↓
Réécrire : /dashboard → /[tenant]/dashboard (réécriture interne)
↓
Page : Lit le locataire depuis les params ou les en-têtes, récupère les données tenant-spécifiques
Ce qui se passe vraiment c'est un tour de magie—les réécritures middleware sont invisibles aux utilisateurs. Leur navigateur affiche toujours fièrement acme.votreapp.com/dashboard, tandis que dans les coulisses, Next.js route vraiment vers /acme/dashboard.
Structure du 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 ne serait-ce qu'une ligne, vous devez vous battre avec DNS.
Vercel
Celui-ci est simple : allez dans les paramètres de votre projet et ajoutez un domaine wildcard :
*.votreapp.com
votreapp.com
Et ne vous inquiétez pas, Vercel fait le gros du travail avec les certificats SSL pour vous. Depuis 2025, vous avez besoin d'au moins un plan Pro (c'est $20/mois par membre de votre équipe) puisque les plans hobby n'ont pas de chance ici.
Cloudflare
Cloudflare joue bien avec le routage wildcard. Configurez un enregistrement A comme ceci :
Type : A
Nom : *
Contenu : <votre-ip-serveur>
Proxy : Oui (cloud orange)
Et si vous êtes dans la gang Vercel, échangez un enregistrement CNAME :
Type : CNAME
Nom : *
Contenu : cname.vercel-dns.com
Proxy : DNS uniquement (cloud gris)
Pourquoi gris ? Vercel gère SSL et le proxy de Cloudflare ne joue pas bien là. Neutre est votre ami.
Auto-hébergé (Nginx)
server {
listen 80;
server_name *.votreapp.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 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'il n'y paraît.
Construire le Middleware
Ok, vous voulez du code ? Voici le middleware qui a été testé au combat. Je vous épargne la gloire de chaque point-virgule mais croyez-moi, c'est de l'or.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// Les domaines qui ne doivent PAS être traités comme des sous-domaines tenant
const RESERVED_SUBDOMAINS = new Set([
'www',
'api',
'admin',
'app',
'mail',
'blog',
'docs',
'status',
]);
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'votreapp.com';
export const config = {
matcher: [
/*
* Correspondre à tous les chemins sauf :
* - _next/static (fichiers statiques)
* - _next/image (optimisation d'image)
* - 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') || '';
// Supprimer le port pour le développement local
const currentHost = hostname.replace(/:\d+$/, '');
// Vérifier s'il s'agit du domaine racine ou www
if (
currentHost === ROOT_DOMAIN ||
currentHost === `www.${ROOT_DOMAIN}` ||
currentHost === 'localhost'
) {
// Ceci est le site marketing / page d'accueil
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 tenant-spécifique
const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
tenantUrl.search = url.search;
const response = NextResponse.rewrite(tenantUrl);
// Injecter les infos tenant en tant qu'en-têtes 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, cachlez 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—fichiers statiques, images, tout. Et c'est un billet aller pour plus de 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 au Lieu de Rediriger ?
La réécriture n'interfère pas avec ce que les utilisateurs voient dans leur navigateur—c'est de la navigation en douceur. Les redirects ? 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.

Stratégies de Résolution de Locataire
Le middleware ci-dessus extrait les locataires des sous-domaines comme un pro. Mais l'existence du locataire ? Faut confirmer ça. Différents coups pour différentes mains quand il s'agit de stratégies.
Stratégie 1 : Recherche de Base de Données dans Middleware
Voilà le hic : Next.js middleware s'exécute sur Edge Runtime, ce qui signifie dire au revoir à beaucoup de ces APIs Node.js douillettes.
// 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
Celle-ci est mon préféré. 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 Blanche Statique
Petite opération ? Les listes blanches statiques peuvent être votre ami—des fichiers JSON au moment de la construction qui gardent les choses serrées et sans 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;
}
Reconstructuisez avec ISR ou webhooks pour gérer les nouveaux locataires dynamiquement.
Conception de Base de Données pour Multi-Locataire
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 Locataire
Voulez-vous une isolation de premier ordre qui s'adapte aux exigences de conformité comme un gant ? Une base de données par locataire est le chemin. Le branchement de Neon et la tarification par base de données de PlanetScale rendent cela réalisable, même pour de petites fortunes en 2025.
Support de Domaine Personnalisé
C'est là que le déploiement et la vanité se rencontrent. Les locataires ont besoin que leurs domaines personnalisés enregistrés CNAME pointent votre chemin. Et SSL ? Chaque lien cliqué doit briller avec ce HTTPS.
API de Domaines Personnalisés Vercel
L'API de Vercel rend cela aussi sans douleur 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 la correspondance domaine-à-locataire
await db.customDomain.create({
data: { domain, tenantId, verified: false },
});
}
return response.json();
}
Vercel gère SSL une fois que DNS s'aligne. Jetez un coup d'œil à leur Platforms Starter Kit—c'est une implémentation modèle.
Cloudflare pour SaaS
C'est idéal pour les gestionnaires DNS personnalisés élégants, car « SSL for SaaS » de Cloudflare apporte la provisioning SSL et les proxies également.
Performance et Mise en Cache
Quand on gère des requêtes, chaque nanoseconde compte. L'astuce consiste à accélérer la résolution des locataires.
Mettre en Cache la Résolution du Locataire
Vous sauverez la journée (et le serveur) en mettant en cache les recherches de locataires :
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;
}
Remembrez cependant que les caches en mémoire ne sont pas pour Edge Runtime. Allez avec quelque chose comme Upstash Redis—c'est ce qu'ils font le mieux.
ISR et Multi-Locataire
ISR adore la multi-locataire car c'est intelligent. Chaque locataire obtient une version en cache unique en raison de la réécriture de /dashboard vers /acme/dashboard. Pas de configs additionnelles, basez-vous juste 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 | ✅ | ✅ | ✅ (manuel) |
| API de Domaines Personnalisés | ✅ | ✅ | ❌ (manuel) |
| Middleware Edge | ✅ | ✅ | ❌ (Node uniquement) |
| SSL Auto | ✅ | ✅ | ⚠️ Let's Encrypt |
| Tarification | $20/siège/mo + utilisation | $5/mo + utilisation | $50-200/mo serveur |
| Max Domaines Personnalisés | 50+ | Illimité | Illimité |
Pour un lancement brillant et rapide, Vercel Pro est votre ticket. Mais quand ces chiffres grimpent—plus d'utilisateurs, plus de requêtes—les Pages Cloudflare ou les options auto-hébergées vous donnent flexibilité et abordabilité.
Nous avons navigué dans les merveilles multi-locataires avec chaque méthode, donc si vous vous posez des questions sur votre prochaine étape, nos capacités de développement Next.js pourraient être votre guide.
Considérations de Sécurité
La multi-locataire est super cool jusqu'à ce qu'elle ne le soit pas. Chaque locataire s'attend à ce que ses données soient sécurisées—sans fuites, sans glissades.
Liste de Contrôle d'Isolation du Locataire
- Filtrer par ID Tenant : Ne faites jamais confiance aux URLs seules. Les vérifications de backend comptent aussi.
- PostgreSQL RLS : C'est comme avoir un détail de sécurité sur votre base de données 24/7.
- Assainir les Sous-domaines : Ne glissez jamais—permettez uniquement
[a-z0-9-]. Évitez les prises de contrôle de sous-domaine. - Limiter le Débit par Locataire : Les en-têtes dynamiques pour le throttle API peuvent vous sauver.
- Enregistrer, Auditer, Examiner : Chaque opération d'écriture devrait dire « gotcha ». La confiance instille la confiance.
// Valider le slug de locataire 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-Locataire
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-Locataire 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 votreapp.local
127.0.0.1 acme.votreapp.local
127.0.0.1 globex.votreapp.local
Puis mettez à jour votre middleware pour reconnaître .votreapp.local pendant le développement :
const ROOT_DOMAIN = process.env.NODE_ENV === 'development'
? 'votreapp.local:3000'
: 'votreapp.com';
Option 2 : Utiliser nip.io ou sslip.io
Ces services tirent les mappages d'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 : Tunnel Local 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.votreapp.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://votreapp.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.votreapp.com/');
const res = await middleware(req);
expect(res.status).toBe(307); // Redirect
});
});
En pensant à la vue d'ensemble ? Que ce soit le choix entre Next.js ou Astro, envisagez de sauter sur notre voyage d'insights de couche CMS headless.
N'oubliez pas, explorer signifie poser des questions, s'adapter et grandir. Nous contactez pour une séance de brainstorm ou plongez plus profondément dans nos tarifs si vous visez un partenariat de première classe.