How to Build Multi-Tenant Subdomains with Next.js 15 and Middleware
Building a SaaS where each customer gets their own subdomain (customer.yourdomain.com)? Here's how I did it with Next.js 15 App Router.
The Architecture
tenant-a.listkars.com → Same Next.js app
tenant-b.listkars.com → Same Next.js app
tenant-c.listkars.com → Same Next.js app
One deployment, many tenants. The subdomain determines which data to show.
3 Step 1: Middleware to Extract Subdomain
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const host = request.headers.get('host') ?? '';
const subdomain = host.split('.')[0];
// Skip for main domain
if (subdomain === 'www' || subdomain === 'listkars') {
return NextResponse.next();
}
// Pass subdomain to the app via header
const response = NextResponse.next();
response.headers.set('x-tenant-slug', subdomain);
return response;
}
Step 2: Read Tenant in Server Components
// app/[[...slug]]/page.tsx
import { headers } from 'next/headers';
export default async function Page() {
const headersList = await headers();
const tenantSlug = headersList.get('x-tenant-slug') ?? 'unknown';
// Fetch tenant data from API
const tenant = await fetch(`${API_URL}/storefront/${tenantSlug}`);
// Render tenant's theme with their data
}
Step 3: Database Design
Single database with tenant_id on every table:
CREATE TABLE cars (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
title VARCHAR(255),
price DECIMAL(12,2),
-- ... other fields
);
CREATE INDEX idx_cars_tenant ON cars(tenant_id);
Every query filters by tenant_id. No data leakage between tenants.
Step 4: Reverse Proxy (Caddy)
*.listkars.com {
reverse_proxy storefront:3001 {
header_up X-Tenant-Slug {labels.3}
}
}
Caddy extracts the subdomain and passes it as a header.
The Result
Each dealer at ListKars gets their own subdomain with a unique theme, car listings, and lead management — all from a single Next.js deployment.
Key Takeaways
- Middleware for routing — extract subdomain, pass as header
- Single DB, tenant_id everywhere — simple and scales well
- One deployment — no per-tenant infrastructure
-
ISR caching —
revalidate = 60means pages are fast but data stays fresh
This pattern powers listkars.com — a free platform for car dealers to create branded websites.
Top comments (0)