Why Subdomain-Based Multi-Tenancy

Let's break this down. You've generally got three options when dealing with multi-tenant routing in SaaS apps:

Pattern Example Isolation SEO User Experience
Path-based app.com/tenant-a/dashboard Low Shared domain authority Feels like a shared platform
Subdomain-based tenant-a.app.com Medium Subdomain authority Feels like a dedicated app
Custom domain app.tenant-a.com High Full domain authority Feels fully white-labeled

Why do we often land on subdomains? Well, it's that Goldilocks situation—just right for 95% of cases. The tenant gets their own branded URL (acme.yourapp.com), and the complexity? Manageable. Plus, you're not stuck when they want to upgrade to a custom domain later on. It feels personal enough for the tenant and keeps your tech stack from turning into a Rube Goldberg machine.

But here's the kicker: start with subdomains and offer those custom domains as a premium feature. With Next.js middleware, this can all flow through one neat pipeline. Talk about efficiency, right?

Wildcard Subdomain Next.js Middleware for Multi-Tenant SaaS

Architecture Overview

Imagine every request entering your app like it's on a conveyor belt. The first thing it encounters? Your Next.js middleware. This trusty bit of code pulls out the subdomain, figures out which tenant it belongs to, and then either rewrites the internal path or flags this request with headers your app can use. Easy peasy!

Request: acme.yourapp.com/dashboard
    ↓
Middleware: Extract hostname → resolve tenant → inject headers
    ↓
Rewrite: /dashboard → /[tenant]/dashboard (internal rewrite)
    ↓
Page: Reads tenant from params or headers, fetches tenant-specific data

What's really happening here is a magic trick—middleware rewrites are invisible to users. Their browser still proudly displays acme.yourapp.com/dashboard, while behind the curtain, Next.js really routes to /acme/dashboard.

Directory Structure

Here's a peek at what your project might look like:

├── middleware.ts
├── app/
│   ├── [tenant]/
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   ├── dashboard/
│   │   │   └── page.tsx
│   │   └── settings/
│   │       └── page.tsx
│   └── api/
│       └── tenant/
│           └── route.ts
├── lib/
│   ├── tenant.ts
│   └── middleware-utils.ts

Setting Up Wildcard DNS

Before you code a single line, you need to wrestle with DNS.

Vercel

This one's straightforward: head to your project settings, and add a wildcard domain:

*.yourapp.com
yourapp.com

And don't sweat it, Vercel does the heavy lifting with SSL certificates for you. As of 2025, you'll need at least a Pro plan (that's $20/month per member of your team) since hobby plans are out of luck here.

Cloudflare

Cloudflare plays nice with wildcard routing. Set up an A record like so:

Type: A
Name: *
Content: <your-server-ip>
Proxy: Yes (orange cloud)

And if you're in the Vercel gang, swap in a CNAME record:

Type: CNAME
Name: *
Content: cname.vercel-dns.com
Proxy: DNS only (gray cloud)

Why gray? Vercel handles SSL, and Cloudflare's proxy doesn't play well there. Neutral is your friend.

Self-Hosted (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;
    }
}

For SSL, you'll need a wildcard cert from Let's Encrypt. Their DNS-01 challenge and Certbot plugin make this saner than it sounds.

Building the Middleware

Okay, you want code? Here's the middleware that's battle-tested. I'll spare you the glory of each semicolon but trust me, it's gold.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

// Domains that should NOT be treated as tenant subdomains
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') || '';
  
  // Remove port for local development
  const currentHost = hostname.replace(/:\d+$/, '');
  
  // Check if this is the root domain or www
  if (
    currentHost === ROOT_DOMAIN ||
    currentHost === `www.${ROOT_DOMAIN}` ||
    currentHost === 'localhost'
  ) {
    // This is the marketing site / landing page
    return NextResponse.next();
  }
  
  // Extract 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 {
    // This might be a custom domain
    tenant = await resolveCustomDomain(currentHost);
  }
  
  if (!tenant) {
    // Unknown domain — redirect to main site
    return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
  }
  
  // Rewrite to tenant-specific path
  const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
  tenantUrl.search = url.search;
  
  const response = NextResponse.rewrite(tenantUrl);
  
  // Inject tenant info as headers for downstream consumption
  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 production, cache this aggressively
    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;
  }
}

The Matcher Pattern Is Everything

You want speed? That config.matcher regex is your best friend. Without it, every request drags middleware along for the ride—static assets, images, all of it. And that's a one-way ticket to 200ms+ extra latency. Nobody wants that. Look at this: /((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)。This alone cut our P95 latency by 40%! Yeah, it's kinda a big deal.

Why Rewrite Instead of Redirect?

Rewriting doesn't mess with what users see in their browser—we're talking smooth sailing. Redirects? Those are like asking your users to trust you'll get them to the right spot—another URL in the box. Stick with rewrite() for a drama-free user experience.

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

Tenant Resolution Strategies

The middleware above yanks tenants from subdomains like a pro. But tenant existence? Gotta confirm that. Different strokes for different folks when it comes to strategies.

Strategy 1: Database Lookup in Middleware

Here's the catch: Next.js middleware runs on the Edge Runtime, which means saying goodbye to many of those cozy Node.js comfort APIs.

// Works with Prisma Accelerate or @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 },
  });
}

Strategy 2: KV Store Lookup

This one's my go-to. Store your slugs in a KV store like Vercel KV, Upstash Redis, or Cloudflare KV. Edge lookups in 1-5ms mean negligible delay.

import { kv } from '@vercel/kv';

async function resolveTenant(slug: string) {
  const tenant = await kv.get(`tenant:${slug}`);
  return tenant as TenantConfig | null;
}

Strategy 3: Static Allow-List

Tiny operation? Static allow-lists can be your friend—build time JSON files that keep things tight and zero-network.

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 with ISR or webhooks to handle new tenants dynamically.

Database Design for Multi-Tenancy

This is where things get serious. Two major paths to pick from:

Shared Database with Tenant ID

Add a tenantId column like it's on sale:

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);

And yes, PostgreSQL RLS is your insurance policy:

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

Life gets easier when you set context:

await db.execute(sql`SELECT set_config('app.current_tenant', ${tenantId}, true)`);

Database Per Tenant

Want top-notch isolation that fits compliance requirements like a glove? A database per tenant is the way to go. Neon's branching and PlanetScale's cool per-database pricing make this feasible, even for small fortunes in 2025.

Custom Domain Support

This is where deployment and vanity meet. Tenants need their CNAME-recorded custom domains to point your way. And SSL? Every clicked link needs to glisten with that HTTPS.

Vercel Custom Domains API

Vercel's API makes this about as painless as it can be:

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) {
    // Store the domain-to-tenant mapping
    await db.customDomain.create({
      data: { domain, tenantId, verified: false },
    });
  }
  return response.json();
}

Vercel handles SSL once DNS lines up. Check out their Platforms Starter Kit—it's a model implementation.

Cloudflare for SaaS

This is ideal for the fancy custom DNS managers, as Cloudflare's "SSL for SaaS" tosses in SSL provisioning and proxies to boot.

Performance and Caching

When handling requests, every nanosecond counts. The trick is speeding tenant resolution.

Cache Tenant Resolution

You'll save the day (and server) by caching tenant lookups:

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;
}

Remember though, in-memory caches aren't for Edge Runtime. Go with something like Upstash Redis—it's what they do best.

ISR and Multi-Tenancy

ISR loves multi-tenancy because it's smart. Each tenant gets a cached version uniquely due to rewriting from /dashboard to /acme/dashboard. No additional configs, just bask in the glory of Next.js magic.

Deployment Configurations

Decisions, decisions. Let's compare deployment options:

Feature Vercel Pro Cloudflare Pages Self-Hosted (Docker)
Wildcard Domains ✅ (manual)
Custom Domains API ❌ (manual)
Edge Middleware ❌ (Node only)
Auto SSL ⚠️ Let's Encrypt
Pricing $20/seat/mo + usage $5/mo + usage $50-200/mo server
Max Custom Domains 50+ Unlimited Unlimited

For a shiny and fast-startup launch, Vercel Pro is your ticket. But when those numbers climb—more users, more requests—Cloudflare Pages or self-hosted options give you flexibility and affordability.

We've navigated multi-tenant wonderlands with each method, so if you're pondering your next step, our Next.js development capabilities might be your guide.

Security Considerations

Multi-tenancy is super cool until it's not. Every tenant expects their data secure—no leaks, no slips.

Tenant Isolation Checklist

  1. Filter by Tenant ID: Never trust URLs alone. Back-end checks matter too.
  2. PostgreSQL RLS: It's like having a security detail on your database 24/7.
  3. Sanitize Subdomains: Don't ever slip—allow [a-z0-9-] only. Avoid subdomain takeovers.
  4. Rate Limit Per Tenant: Dynamic headers for API throttle can save your bacon.
  5. Log, Audit, Review: Each write operation should say "gotcha." Confidence instills trust.
// Validate 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 and Multi-Tenancy

Your API serves many subdomains, you need CORS to not throw tantrums:

// 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
}

Testing Multi-Tenant Middleware Locally

Headaches abound if local dev doesn't support subdomains. Here's how you dream again.

Option 1: Edit `/etc/hosts`

# /etc/hosts
127.0.0.1 yourapp.local
127.0.0.1 acme.yourapp.local
127.0.0.1 globex.yourapp.local

Then update your middleware to recognize .yourapp.local during development:

const ROOT_DOMAIN = process.env.NODE_ENV === 'development' 
  ? 'yourapp.local:3000' 
  : 'yourapp.com';

Option 2: Use nip.io or sslip.io

These services pull out magic IP mappings:

acme.127.0.0.1.nip.io → 127.0.0.1
globex.127.0.0.1.nip.io → 127.0.0.1

Simple, doesn't require editing hosts.

Option 3: Local Tunnel with Custom Subdomains

Use ngrok (or the likes) for quick tunnels:

ngrok http 3000 --hostname=*.your-dev-domain.ngrok.io

Testing shouldn't be harder than real-world scenarios.

Writing Integration Tests

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
  });
});

Thinking about the bigger picture? Whether it's the choice between Next.js or Astro, consider hopping onto our headless CMS layer insights journey.


Remember, exploring means asking, adapting, and growing. Hit us up for a brainstorm session or dig deeper into our pricing if you're aiming for top-tier partnership.