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!

Wildcard Subdomain Next.js Middleware voor Multi-Tenant SaaS

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.

Wildcard Subdomain Next.js Middleware voor Multi-Tenant SaaS - architectuur

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

  1. Filteren op Tenant-ID: Vertrouw niet alleen op URL's. Back-endchecks zijn belangrijk.
  2. PostgreSQL RLS: Het is als een beveiligingsteam 24/7 op je database.
  3. Subdomein ontsmetten: Slip niet uit—alleen [a-z0-9-] toestaan. Vermijd subdomain-overname.
  4. Snelheidsbeperking per tenant: Dynamische headers voor API-throttle kunnen je bacon redden.
  5. 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.