Wildcard Subdomain Next.js Middleware voor Multi-Tenant SaaS
Waarom op subdomein gebaseerde multi-tenancy
Laten we dit uitpakken. Je hebt over het algemeen drie opties als je te maken hebt met multi-tenant routing in SaaS-apps:
| Patroon | Voorbeeld | Isolatie | SEO | Gebruikerservaring |
|---|---|---|---|---|
| Op pad gebaseerd | app.com/tenant-a/dashboard |
Laag | Gedeeld domeinautoriteit | Voelt als een gedeeld platform |
| Op subdomein gebaseerd | tenant-a.app.com |
Gemiddeld | Subdomeinautoriteit | Voelt als een dedicated app |
| Aangepast domein | app.tenant-a.com |
Hoog | Volledige domeinautoriteit | Voelt volledig white-labeled |
Waarom landen we vaak op subdomein? Nou, het is dat Goldilock-moment—precies goed voor 95% van de gevallen. De tenant krijgt zijn eigen gebrandde URL (acme.yourapp.com), en de complexiteit? Te beheren. Plus, je bent niet vast als ze later willen upgraden naar een aangepast domein. Het voelt persoonlijk genoeg voor de tenant en houdt je tech stack ervoor af om in een Rube Goldberg-machine te veranderen.
Maar hier is de twist: begin met subdomein en bied die aangepaste domeinen aan als premium-feature. Met Next.js middleware kan dit allemaal door één nette pipeline lopen. Spreek je van efficiëntie!

Architectuuraanzicht
Stellen je voor dat elk verzoek je app binnengaat als op een transportband. Het eerste waar het mee te maken krijgt? Je Next.js middleware. Dit betrouwbare stukje code haalt het subdomein eruit, figureert uit welke tenant het toepast, en herschrijft vervolgens ofwel het interne pad ofwel markeert het verzoek met headers die je app kan gebruiken. Makkelijk zat!
Verzoek: acme.yourapp.com/dashboard
↓
Middleware: Extract hostname → resolve tenant → inject headers
↓
Herschrijven: /dashboard → /[tenant]/dashboard (interne herschrijving)
↓
Pagina: Leest tenant uit params of headers, haalt tenant-specifieke data op
Wat hier echt gebeurt is een goocheltruc—middleware-herschrijvingen zijn onzichtbaar voor gebruikers. Hun browser toont nog steeds trots acme.yourapp.com/dashboard, terwijl Next.js achter de schermen werkelijk naar /acme/dashboard routeert.
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 met DNS worstelen.
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 2025 heb je minstens een Pro-plan nodig (dat is $20/maand per teamlid) omdat hobby-plans geen geluk hebben.
Cloudflare
Cloudflare werkt prettig met wildcard-routering. Stel een A-record als volgt in:
Type: A
Naam: *
Inhoud: <your-server-ip>
Proxy: Ja (oranje wolk)
En als je in de Vercel-groep zit, verwissel je in een CNAME-record:
Type: CNAME
Naam: *
Inhoud: cname.vercel-dns.com
Proxy: Alleen DNS (grijze wolk)
Waarom grijs? Vercel handelt SSL af, en de proxy van Cloudflare 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-challenge en Certbot-plugin maken dit verstandiger dan het klinkt.
De Middleware bouwen
Oké, je wilt code? Hier is de middleware die in productie is getest. Ik bespaar je de glorie van elk puntkomma, maar vertrouw me, het is goud.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// Domeinen die NIET als tenant-subdomein 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: [
/*
* Match all paths except:
* - _next/static (static files)
* - _next/image (image optimization)
* - favicon.ico
* - public folder files
*/
'/((?!_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') || '';
// Poort verwijderen voor lokale development
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 marketing site / landingspagina
return NextResponse.next();
}
// Extract subdomein
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 kan een aangepast domein zijn
tenant = await resolveCustomDomain(currentHost);
}
if (!tenant) {
// Onbekend domein — doorverwijzen 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 stroomafwaarts gebruik
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
Wil je 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 hiernaar: /((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*). Dit alleen sneed onze P95-latentie met 40% af! Ja, het is soort van een groot ding.
Waarom herschrijven in plaats van doorverwijzen?
Herschrijven verstoort niet wat gebruikers in hun browser zien—we spreken van soepel varen. Doorverwijzingen? Die zijn als je gebruikers vragen je te vertrouwen je ze op de juiste plek afzet—nog een URL in de doos. Plak je aan rewrite() voor een drama-loze gebruikerservaring.

Tenant-resolutiestrategieën
De middleware hierboven haalt tenants uit subdomein als een pro. Maar tenant-bestaan? Dat moet bevestigd worden. Verschillende slagen voor verschillende situaties als het gaat om strategieën.
Strategie 1: Database-zoekopdracht in Middleware
Hier is de vangst: Next.js middleware draait op de Edge Runtime, wat afscheid nemen van veel van die gezellige Node.js comfort-API's betekent.
// 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-opslagzoekopdracht
Deze is mijn favoriet. Sla je slugs op in een KV-opslag zoals Vercel KV, Upstash Redis of Cloudflare KV. Edge-zoekopdrachten in 1-5ms betekenen 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—JSON-bestanden bij buildtijd die dingen strak en nul-netwerk 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;
}
Hercompileer met ISR of webhooks om nieuwe tenants dynamisch af te handelen.
Databaseontwerp voor Multi-Tenancy
Dit is waar het serieus wordt. Twee grote paden om uit te kiezen:
Gedeelde database met Tenant-ID
Voeg een tenantId-kolom toe alsof het in de uitverkoop 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 isolatie van topklasse die past bij nalevingsvereisten als een handschoen? Een database per tenant is de weg. Neon's branching en PlanetScale's coole per-database-prijzen maken dit haalbaar, zelfs voor kleine fortuin in 2025.
Ondersteuning voor aangepast domein
Hier is waar implementatie en vanity elkaar ontmoeten. Tenants hebben hun CNAME-opgenomen aangepaste domeinen nodig om naar jou te wijzen. En SSL? Elk aangeklikt link moet glinsteren met die HTTPS.
Vercel Custom Domains API
De API van Vercel maakt dit ongeveer zo pijnloos als het kan:
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-toewijzing op
await db.customDomain.create({
data: { domain, tenantId, verified: false },
});
}
return response.json();
}
Vercel handelt SSL af zodra DNS aansluit. Check hun Platforms Starter Kit—het is een modelimplementatie.
Cloudflare voor SaaS
Dit is ideaal voor de mooie aangepaste DNS-managers, aangezien "SSL voor SaaS" van Cloudflare SSL-inrichting en proxies opwarmt.
Prestatie en caching
Bij het afhandelen van verzoeken is elke nanoseconde belangrijk. Het trucje is het versnellen van tenant-resolutie.
Tenant-resolutie in cache opslaan
Je redt de dag (en server) door tenant-zoekopdrachten in cache op te slaan:
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;
}
Onthoud echter, in-memory caches zijn niet voor Edge Runtime. Ga met iets als Upstash Redis—het is wat ze het beste doen.
ISR en Multi-Tenancy
ISR houdt van multi-tenancy omdat het intelligent is. Elke tenant krijgt een unieke versie in cache dankzij herschrijving van /dashboard naar /acme/dashboard. Geen aanvullende configs, geniet gewoon van Next.js-magie.
Implementatieconfiguraties
Beslissingen, beslissingen. Laten we implementatieopties vergelijken:
| Functie | Vercel Pro | Cloudflare Pages | Zelf-gehost (Docker) |
|---|---|---|---|
| Wildcard-domeinen | ✅ | ✅ | ✅ (handmatig) |
| Aangepaste domeinen API | ✅ | ✅ | ❌ (handmatig) |
| Edge Middleware | ✅ | ✅ | ❌ (alleen Node) |
| Auto SSL | ✅ | ✅ | ⚠️ Let's Encrypt |
| Prijsstelling | $20/seat/mo + usage | $5/mo + usage | $50-200/mo server |
| Max aangepaste domeinen | 50+ | Onbeperkt | Onbeperkt |
Voor een glimmende en snelle startup-lancering is Vercel Pro je kaartje. Maar als die nummers stijgen—meer gebruikers, meer verzoeken—geven Cloudflare Pages of zelf-gehoste opties je flexibiliteit en betaalbaarheid.
We navigeren met elke methode door multi-tenant-wonderlanden, dus als je je volgende stap overweegt, onze Next.js-ontwikkelingscapaciteiten kan je gids zijn.
Veiligheidoverwegingen
Multi-tenancy is super cool totdat het niet is. Elke tenant verwacht dat hun data beveiligd is—geen lekken, geen uitglijders.
Checklist voor tenantisolatie
- Filteren op Tenant-ID: Vertrouw niet alleen op URL's. Back-endchecks zijn belangrijk.
- PostgreSQL RLS: Het is als een beveiligingsteam 24/7 op je database.
- Subdomein ontsmetten: Slip niet uit—alleen
[a-z0-9-]toestaan. Vermijd subdomain-overname. - Snelheidsbeperking per tenant: Dynamische headers voor API-throttle kunnen je bacon redden.
- Loggen, controleren, beoordelen: Elk schrijfbewerking zou "gotcha" moeten zeggen. Vertrouwen instellen doet vertrouwen.
// 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 subdomein, je hebt CORS nodig zodat het niet in woede uitbarst:
// 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 of handler
}
Multi-Tenant Middleware lokaal testen
Headaches wachten als lokale dev geen subdomein ondersteunt. Hier is hoe je droomt terug.
Optie 1: `/etc/hosts` bewerken
# /etc/hosts
127.0.0.1 yourapp.local
127.0.0.1 acme.yourapp.local
127.0.0.1 globex.yourapp.local
Update vervolgens je middleware om .yourapp.local tijdens development te herkennen:
const ROOT_DOMAIN = process.env.NODE_ENV === 'development'
? 'yourapp.local:3000'
: 'yourapp.com';
Optie 2: nip.io of sslip.io gebruiken
Deze services trekken magische IP-toewijzingen 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 geen host-bewerking.
Optie 3: Lokale tunnel met aangepaste subdomein
Gebruik ngrok (of gelijkaardige) voor snelle tunnels:
ngrok http 3000 --hostname=*.your-dev-domain.ngrok.io
Testen mag niet moeilijker zijn dan echte scenario's.
Integratietesten schrijven
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
});
});
Als je het grotere plaatje overweegt? Of je kiest tussen Next.js of Astro, overweeg om op onze headless CMS-laag-inzichten reis te stappen.
Onthoud, verkennen betekent vragen, aanpassen en groeien. Neem contact met ons op voor een brainstorm-sessie of duik dieper in onze prijzen als je naar topkwaliteits-partnership streeft.