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?

Wildcard Subdomain Next.js Middleware for Multi-Tenant SaaS

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.

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

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

  1. Filter op Tenant ID: Vertrouw nooit alleen op URL's. Back-end checks zijn ook belangrijk.
  2. PostgreSQL RLS: Het is alsof je 24/7 een beveiligingsteam op je database hebt.
  3. Zuiver subdomains: Slip nooit uit — sta alleen [a-z0-9-] toe. Vermijd subdomain-overnamen.
  4. Tarief beperken per tenant: Dynamische headers voor API-throttle kunnen je redden.
  5. 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.