Wildcard Subdomain Routing in Next.js Middleware (Multi-Tenant SaaS)
Waarom Subdomain-gebaseerde Multi-Tenancy
Je middleware wordt uitgevoerd op het moment dat een verzoek bij je Next.js-app aankomt — voordat enige pagina wordt weergegeven, voordat enige API-route wordt uitgevoerd. Een tenant typt customer1.yourapp.com in zijn browser. Een ander opent customer2.yourapp.com. Elk subdomain moet naar geïsoleerde dashboards leiden, naar afzonderlijke databases, naar verschillende feature flags — allemaal vanuit één codebase. De meeste ontwikkelaars grijpen naar op paden gebaseerde routing (/tenant-slug/dashboard) en erven een puinhoop van URL-herschrijvingen en auth-controles verspreid over twintig bestanden. Wildcard-subdomains stellen je in staat om *.yourapp.com aan de rand op te vangen, de tenant-identifier in middleware uit te pakken en context in te voeren voordat React ooit wordt gemount. Maar drie beslissingen bepalen of je implementatie schaalt naar 10.000 tenants of stilvalt bij 50.
| Patroon | Voorbeeld | Isolatie | SEO | Gebruikerservaring |
|---|---|---|---|---|
| Op pad gebaseerd | app.com/tenant-a/dashboard |
Laag | Gedeelde domeinautoriteit | Voelt als een gedeeld platform |
| Subdomain-gebaseerd | tenant-a.app.com |
Gemiddeld | Subdomain-autoriteit | Voelt als een eigen app |
| Aangepast domein | app.tenant-a.com |
Hoog | Volledige domeinautoriteit | Voelt volledig white-labeled |
Waarom kiezen we vaak voor subdomains? Nou, het is die Goudlokje-situatie — precies goed voor 95% van de gevallen. De tenant krijgt zijn eigen URL (acme.yourapp.com), en de complexiteit? Beheersbaar. Plus, je bent niet vast zitten als zij later willen upgraden naar een aangepast domein. Het voelt persoonlijk genoeg voor de tenant en zorgt ervoor dat je tech stack niet in een Rube Goldberg-machine verandert.
Maar hier is het probleem: begin met subdomains en bied die aangepaste domeinen aan als een premium-functie. Met Next.js middleware kan dit allemaal door één nette pipeline stromen. Spreek over efficiëntie, toch?

Architectuuraanzicht
Stellend je voor dat elk verzoek je app binnenkomt als op een lopende band. Het eerste wat het tegenkomt? Je Next.js middleware. Dit betrouwbare stukje code trekt het subdomain eruit, berekent tot welke tenant het behoort, en herschrijft dan het interne pad of markeert dit verzoek met headers die je app kan gebruiken. Makkelijk zat!
Verzoek: acme.yourapp.com/dashboard
↓
Middleware: Haal hostnaam op → los tenant op → injecteer headers
↓
Herschrijven: /dashboard → /[tenant]/dashboard (interne herschrijving)
↓
Pagina: Leest tenant uit params of headers, haalt tenant-specifieke gegevens op
Wat hier echt gebeurt is een goocheltruc — middleware-herschrijvingen zijn onzichtbaar voor gebruikers. Hun browser toont trots acme.yourapp.com/dashboard, terwijl achter de schermen Next.js echt naar /acme/dashboard routert.
Mappenstructuur
Hier is een blik op hoe je project er uit zou kunnen zien:
├── middleware.ts
├── app/
│ ├── [tenant]/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── dashboard/
│ │ │ └── page.tsx
│ │ └── settings/
│ │ └── page.tsx
│ └── api/
│ └── tenant/
│ └── route.ts
├── lib/
│ ├── tenant.ts
│ └── middleware-utils.ts
Wildcard DNS instellen
Voordat je een enkele regel code schrijft, moet je tegen DNS strijden.
Vercel
Deze is eenvoudig: ga naar je projectinstellingen en voeg een wildcard-domein toe:
*.yourapp.com
yourapp.com
En maak je geen zorgen, Vercel doet het zware werk met SSL-certificaten voor je. Vanaf 2026 heb je minstens een Pro-plan nodig (dat is $20/maand per lid van je team), omdat hobby-plans geen geluk hebben.
Cloudflare
Cloudflare speelt aardig mee met wildcard-routing. Stel een A-record als volgt in:
Type: A
Naam: *
Inhoud: <your-server-ip>
Proxy: Ja (oranje cloud)
En als je in de Vercel-gang zit, wissel je in een CNAME-record:
Type: CNAME
Naam: *
Inhoud: cname.vercel-dns.com
Proxy: Alleen DNS (grijze cloud)
Waarom grijs? Vercel handelt SSL af, en Cloudflare's proxy speelt daar niet goed mee. Neutraal is je vriend.
Zelf gehost (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;
}
}
Voor SSL heb je een wildcard-certificaat van Let's Encrypt nodig. Hun DNS-01-uitdaging en Certbot-plugin maken dit verstandiger dan het klinkt.
De Middleware bouwen
Oke, je wilt code? Hier is de middleware die in het veld is getest. Ik zal je de glorie van elk puntkomma besparen, maar vertrouw me, het is goud.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// Domeinen die NIET als tenant-subdomains moeten worden behandeld
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: [
/*
* Pas alle paden aan behalve:
* - _next/static (statische bestanden)
* - _next/image (afbeeldingsoptimalisatie)
* - favicon.ico
* - bestanden in de public-map
*/
'/((?!_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') || '';
// Verwijder poort voor lokale ontwikkeling
const currentHost = hostname.replace(/:\d+$/, '');
// Controleer of dit het root-domein of www is
if (
currentHost === ROOT_DOMAIN ||
currentHost === `www.${ROOT_DOMAIN}` ||
currentHost === 'localhost'
) {
// Dit is de marketingsite / landingspagina
return NextResponse.next();
}
// Extraheer subdomain
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 {
// Dit zou een aangepast domein kunnen zijn
tenant = await resolveCustomDomain(currentHost);
}
if (!tenant) {
// Onbekend domein — stuur door naar hoofdsite
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
// Herschrijf naar tenant-specifiek pad
const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
tenantUrl.search = url.search;
const response = NextResponse.rewrite(tenantUrl);
// Injecteer tenant-info als headers voor stroomafwaartse verbruik
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 {
// In productie, cache dit agressief
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;
}
}
Het Matcher-patroon is alles
Je wilt snelheid? Die config.matcher-regex is je beste vriend. Zonder het sleept elk verzoek middleware mee — statische assets, afbeeldingen, allemaal. En dat is een eenrichtingskaartje naar 200ms+ extra latentie. Niemand wil dat. Kijk naar dit: /((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*). Dit alleen heeft onze P95-latentie met 40% gereduceerd! Ja, het is soort van een groot ding.
Waarom herschrijven in plaats van doorsturen?
Herschrijven doet geen moeite met wat gebruikers in hun browser zien — we hebben het over soepel zeilen. Doorsturen? Die vragen je gebruikers je te vertrouwen dat je hen naar de juiste plek krijgt — nog een URL in het vak. Hou je vast aan rewrite() voor een dramatische gebruikerservaring.

Tenant-resolutiestrategieën
De middleware hierboven trekt tenants uit subdomains als een profi. Maar tenant-bestaan? Dat moet je bevestigen. Verschillende slagen voor verschillende situaties als het gaat om strategieën.
Strategie 1: Database-opzoeken in Middleware
Hier is de vangst: Next.js middleware wordt uitgevoerd op de Edge Runtime, wat betekent dat je afscheid neemt van veel van die knusse Node.js comfortabele API's.
// Werkt met Prisma Accelerate of @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 },
});
}
Strategie 2: KV Store-opzoeken
Dit is mijn favoriet. Sla je slugs op in een KV-store zoals Vercel KV, Upstash Redis, of Cloudflare KV. Edge-opzoeken in 1-5ms betekent verwaarloosbare vertraging.
import { kv } from '@vercel/kv';
async function resolveTenant(slug: string) {
const tenant = await kv.get(`tenant:${slug}`);
return tenant as TenantConfig | null;
}
Strategie 3: Statische Toestemmingenlijst
Kleine operatie? Statische toestemmingenlijsten kunnen je vriend zijn — bouw JSON-bestanden die dingen strak en nulnetwerk houden.
import tenants from './tenants.json';
const tenantMap = new Map(tenants.map(t => [t.slug, t]));
function resolveTenant(slug: string) {
return tenantMap.get(slug) || null;
}
Rebuild met ISR of webhooks om nieuwe tenants dynamisch af te handelen.
Databaseontwerp voor Multi-Tenancy
Dit is waar dingen serieus worden. Twee grote paden om uit te kiezen:
Gedeelde database met Tenant ID
Voeg een tenantId-kolom toe alsof het te koop is:
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);
En ja, PostgreSQL RLS is je verzekeringspolis:
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Het leven wordt gemakkelijker als je context instelt:
await db.execute(sql`SELECT set_config('app.current_tenant', ${tenantId}, true)`);
Database per tenant
Wil je top-notch isolatie die naadloos in compliancevereisten past? Een database per tenant is de weg. Neon's vertakking en PlanetScale's coole per-database-prijzen maken dit haalbaar, zelfs voor kleine fortuin in 2026.
Ondersteuning aangepaste domein
Dit is waar implementatie en vaniteit elkaar ontmoeten. Tenants moeten hun CNAME-geregistreerde aangepaste domeinen op je manier wijzen. En SSL? Elke geklikte link moet glanzen met HTTPS.
Vercel API aangepaste domeinen
Vercel's API maakt dit ongeveer zo pijnloos mogelijk:
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) {
// Sla de domein-naar-tenant-kaart op
await db.customDomain.create({
data: { domain, tenantId, verified: false },
});
}
return response.json();
}
Vercel handelt SSL af zodra DNS aansluit. Kijk naar hun Platforms Starter Kit — het is een model-implementatie.
Cloudflare voor SaaS
Dit is ideaal voor de ingewikkelde aangepaste DNS-managers, aangezien Cloudflare's 'SSL voor SaaS' SSL-provisioning en proxies toevoegt.
Prestaties en caching
Bij het afhandelen van verzoeken telt elke nanoseconde. De truc is het versnellen van tenant-resolutie.
Cache tenant-resolutie
Je spaart de dag (en server) door tenant-opzoeken in cache op te slaan:
import { LRUCache } from 'lru-cache';
const tenantCache = new LRUCache<string, TenantConfig>({
max: 10000,
ttl: 1000 * 60 * 5, // 5 minuten
});
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;
}
Onthoud echter dat in-memory caches niet voor Edge Runtime zijn. Ga voor iets zoals Upstash Redis — daar zijn ze goed in.
ISR en Multi-Tenancy
ISR houdt van multi-tenancy omdat het slim is. Elke tenant krijgt een cached versie uniek vanwege herschrijving van /dashboard naar /acme/dashboard. Geen extra configs, geniet gewoon van de glorie van Next.js-magie.
Implementatieconfiguraties
Beslissingen, beslissingen. Laten we implementatieopties vergelijken:
| Functie | Vercel Pro | Cloudflare Pages | Zelf gehost (Docker) |
|---|---|---|---|
| Wildcard-domeinen | ✅ | ✅ | ✅ (handmatig) |
| API aangepaste domeinen | ✅ | ✅ | ❌ (handmatig) |
| Edge middleware | ✅ | ✅ | ❌ (alleen Node) |
| Auto SSL | ✅ | ✅ | ⚠️ Let's Encrypt |
| Prijsstelling | $20/seat/mo + gebruik | $5/mo + gebruik | $50-200/mo server |
| Max aangepaste domeinen | 50+ | Ongelimiteerd | Ongelimiteerd |
Voor een glimmende en snelle startup-lancering is Vercel Pro je ticket. Maar wanneer die getallen stijgen — meer gebruikers, meer verzoeken — bieden Cloudflare Pages of zelf-gehoste opties je flexibiliteit en betaalbaarheid.
We hebben multi-tenant-wonderlanden met elke methode genavigeerd, dus als je je volgende stap overweegt, zijn onze Next.js-ontwikkelingscapaciteiten misschien je gids.
Veiligheidsoverwegingen
Multi-tenancy is super gaaf totdat het dat niet is. Elke tenant verwacht dat zijn gegevens veilig zijn — geen lekkages, geen uitglijders.
Controlelijst tenant-isolatie
- Filter op Tenant ID: Vertrouw nooit alleen op URL's. Back-end checks zijn ook belangrijk.
- PostgreSQL RLS: Het is alsof je 24/7 een beveiligingsteam op je database hebt.
- Zuiver subdomains: Slip nooit uit — sta alleen
[a-z0-9-]toe. Vermijd subdomain-overnamen. - Tarief beperken per tenant: Dynamische headers voor API-throttle kunnen je redden.
- Log, controleer, beoordeel: Elke schrijfbewerking zou moeten zeggen "ja hoor". Vertrouwen stelt vertrouwen in.
// Valideer tenant-slug in 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 en Multi-Tenancy
Je API serveert veel subdomains, je hebt CORS nodig zodat het geen driftbuien krijgt:
// 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');
}
// ... rest van handler
}
Multi-Tenant Middleware lokaal testen
Hoofpijnen groeien als lokale dev geen subdomains ondersteunt. Hier is hoe je weer droomt.
Optie 1: Bewerk `/etc/hosts`
# /etc/hosts
127.0.0.1 yourapp.local
127.0.0.1 acme.yourapp.local
127.0.0.1 globex.yourapp.local
Werk vervolgens je middleware bij om .yourapp.local tijdens ontwikkeling te herkennen:
const ROOT_DOMAIN = process.env.NODE_ENV === 'development'
? 'yourapp.local:3000'
: 'yourapp.com';
Optie 2: Gebruik nip.io of sslip.io
Deze services trekken magische IP-mappings uit:
acme.127.0.0.1.nip.io → 127.0.0.1
globex.127.0.0.1.nip.io → 127.0.0.1
Eenvoudig, vereist niet dat je hosts bewerkt.
Optie 3: Lokale tunnel met aangepaste subdomains
Gebruik ngrok (of dergelijk) voor snelle tunnels:
ngrok http 3000 --hostname=*.your-dev-domain.ngrok.io
Testen mag niet moeilijker zijn dan praktijkscenario's.
Integratietest schrijven
import { describe, it, expect } from 'vitest';
import { middleware } from './middleware';
import { NextRequest } from 'next/server';
describe('tenant middleware', () => {
it('herschrijft subdomain-verzoeken naar tenant-pad', 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('gaat door met root-domain-verzoeken', async () => {
const req = new NextRequest('https://yourapp.com/');
const res = await middleware(req);
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
});
it('verwerpt ongeldige subdomain-tekens', async () => {
const req = new NextRequest('https://acme--evil.yourapp.com/');
const res = await middleware(req);
expect(res.status).toBe(307); // Doorsturen
});
});
Als je het grotere plaatje bekijkt? Of je nu kiest tussen Next.js of Astro, overweeg om op onze inzichten in headless CMS-laag reis te springen.
Onthoud, verkennen betekent vragen, aanpassen en groeien. Neem contact met ons op voor een brainstormsessie of duik dieper in onze prijsstelling als je streeft naar partnerschap op topniveau.