Next.js Multi-Tenant Subdomains: Middleware Routing, Per-Tenant Theming, and DNS Setup
Build multi-tenant subdomains in Next.js. Covers middleware-based subdomain routing, per-tenant configuration from database, dynamic theming with CSS variables, custom domain support, and Vercel DNS setup.
Subdomain multi-tenancy โ where each customer gets acme.yourapp.com โ is one of the most compelling SaaS UX patterns. It gives customers a sense of ownership, enables per-tenant branding, and makes it trivial to isolate tenant contexts. Next.js middleware makes this surprisingly clean to implement.
This guide builds the complete subdomain tenant system: middleware routing, tenant config from database, dynamic theming, and custom domain (app.acme.com) support.
Architecture Overview
Request: acme.yourapp.com/projects/123
โ
Next.js Middleware (edge)
โ
Extract hostname โ "acme"
โ
Rewrite to /t/acme/projects/123 (internal URL)
โ
App Router picks up [tenant] segment
โ
Server Component fetches tenant config
โ
Render with tenant theme and data
The key insight: middleware rewrites the URL internally without changing what the user sees. acme.yourapp.com/projects/123 shows in the browser but renders /t/acme/projects/123 internally.
Middleware: Subdomain Routing
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
// Routes that don't need tenant resolution
const PUBLIC_ROUTES = [
"/",
"/pricing",
"/blog",
"/about",
"/auth",
"/api/auth",
"/_next",
"/favicon.ico",
"/images",
];
const APP_DOMAIN = process.env.NEXT_PUBLIC_APP_DOMAIN!; // "yourapp.com"
export function middleware(req: NextRequest) {
const url = req.nextUrl.clone();
const hostname = req.headers.get("host") ?? "";
// Strip port in development
const host = hostname.replace(/:3000$/, "").replace(/:8080$/, "");
// Case 1: Main app domain (yourapp.com) โ no tenant rewrite needed
if (host === APP_DOMAIN || host === `www.${APP_DOMAIN}`) {
return NextResponse.next();
}
// Case 2: Subdomain (acme.yourapp.com)
if (host.endsWith(`.${APP_DOMAIN}`)) {
const subdomain = host.replace(`.${APP_DOMAIN}`, "");
// Skip system subdomains
if (["www", "app", "api", "admin", "mail"].includes(subdomain)) {
return NextResponse.next();
}
// Skip public routes on the main domain pattern
const path = url.pathname;
if (PUBLIC_ROUTES.some((r) => path === r || path.startsWith(r + "/"))) {
return NextResponse.next();
}
// Rewrite: /projects/123 โ /t/acme/projects/123
url.pathname = `/t/${subdomain}${url.pathname}`;
return NextResponse.rewrite(url);
}
// Case 3: Custom domain (app.acme.com)
// Look up tenant by custom domain โ stored in DB, cached in KV
return handleCustomDomain(req, host, url);
}
async function handleCustomDomain(
req: NextRequest,
host: string,
url: URL
): Promise<NextResponse> {
// Check KV cache for custom domain โ tenant mapping
const tenantSlug = await lookupCustomDomain(host);
if (!tenantSlug) {
// Unknown domain โ redirect to main app
return NextResponse.redirect(new URL(`https://${APP_DOMAIN}`));
}
url.pathname = `/t/${tenantSlug}${url.pathname}`;
return NextResponse.rewrite(url);
}
async function lookupCustomDomain(host: string): Promise<string | null> {
// Use Vercel KV or Upstash Redis for fast edge lookup
const cacheKey = `custom-domain:${host}`;
// With Vercel KV:
// const cached = await kv.get<string>(cacheKey);
// if (cached !== null) return cached;
// Fall back to API route (runs on origin, not edge)
try {
const res = await fetch(
`${process.env.INTERNAL_API_URL}/api/internal/custom-domains/${encodeURIComponent(host)}`,
{
headers: { "x-internal-token": process.env.INTERNAL_API_TOKEN! },
next: { revalidate: 300 }, // Cache for 5 minutes at edge
}
);
if (!res.ok) return null;
const data = await res.json();
return data.tenantSlug ?? null;
} catch {
return null;
}
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
App Router: Tenant Layout
// app/t/[tenant]/layout.tsx
import { notFound } from "next/navigation";
import { getTenantConfig } from "@/lib/tenant/config";
import { TenantThemeProvider } from "@/components/tenant/theme-provider";
import { TenantNavbar } from "@/components/tenant/navbar";
interface TenantLayoutProps {
children: React.ReactNode;
params: { tenant: string };
}
export default async function TenantLayout({
children,
params,
}: TenantLayoutProps) {
const config = await getTenantConfig(params.tenant);
if (!config) notFound();
if (!config.isActive) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-gray-500">This workspace is not available.</p>
</div>
);
}
return (
<TenantThemeProvider config={config}>
<div className="min-h-screen bg-background">
<TenantNavbar config={config} />
<main>{children}</main>
</div>
</TenantThemeProvider>
);
}
Tenant Configuration
// lib/tenant/config.ts
import { prisma } from "@/lib/prisma";
import { cache } from "react";
export interface TenantConfig {
id: string;
slug: string;
name: string;
logoUrl: string | null;
faviconUrl: string | null;
customDomain: string | null;
isActive: boolean;
plan: "free" | "starter" | "pro" | "enterprise";
// Theming
primaryColor: string; // "#3b82f6"
accentColor: string; // "#8b5cf6"
borderRadius: "none" | "sm" | "md" | "lg" | "full";
fontFamily: "inter" | "geist" | "system";
// Features enabled for this tenant
features: {
customDomain: boolean;
ssoEnabled: boolean;
apiAccess: boolean;
advancedAnalytics: boolean;
};
}
// React cache() deduplicates within a single request
export const getTenantConfig = cache(
async (slug: string): Promise<TenantConfig | null> => {
const workspace = await prisma.workspace.findUnique({
where: { slug },
select: {
id: true,
slug: true,
name: true,
logoUrl: true,
faviconUrl: true,
customDomain: true,
isActive: true,
plan: true,
primaryColor: true,
accentColor: true,
borderRadius: true,
fontFamily: true,
},
});
if (!workspace) return null;
return {
...workspace,
features: getPlanFeatures(workspace.plan),
};
}
);
function getPlanFeatures(plan: string): TenantConfig["features"] {
return {
customDomain: ["pro", "enterprise"].includes(plan),
ssoEnabled: plan === "enterprise",
apiAccess: ["pro", "enterprise"].includes(plan),
advancedAnalytics: ["pro", "enterprise"].includes(plan),
};
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Dynamic Theme with CSS Variables
// components/tenant/theme-provider.tsx
import type { TenantConfig } from "@/lib/tenant/config";
// Hex to HSL conversion for Tailwind-compatible CSS vars
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
}
const BORDER_RADIUS_MAP = {
none: "0",
sm: "4px",
md: "8px",
lg: "12px",
full: "9999px",
} as const;
interface TenantThemeProviderProps {
config: TenantConfig;
children: React.ReactNode;
}
export function TenantThemeProvider({
config,
children,
}: TenantThemeProviderProps) {
const primaryHsl = hexToHsl(config.primaryColor);
const accentHsl = hexToHsl(config.accentColor);
const borderRadius = BORDER_RADIUS_MAP[config.borderRadius];
const cssVars = `
:root {
--primary: ${primaryHsl};
--accent: ${accentHsl};
--radius: ${borderRadius};
--font-sans: ${config.fontFamily === "geist" ? "Geist, " : ""}${config.fontFamily === "inter" ? "Inter, " : ""}system-ui, sans-serif;
}
`;
return (
<>
<style dangerouslySetInnerHTML={{ __html: cssVars }} />
{/* Load custom font if needed */}
{config.fontFamily === "geist" && (
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
)}
{children}
</>
);
}
Tenant Navbar
// components/tenant/navbar.tsx
import Image from "next/image";
import type { TenantConfig } from "@/lib/tenant/config";
export function TenantNavbar({ config }: { config: TenantConfig }) {
return (
<header className="h-14 border-b border-gray-200 flex items-center px-4 gap-4 bg-white">
{config.logoUrl ? (
<Image
src={config.logoUrl}
alt={`${config.name} logo`}
width={120}
height={32}
className="h-8 w-auto object-contain"
/>
) : (
<span
className="text-lg font-bold"
style={{ color: `hsl(var(--primary))` }}
>
{config.name}
</span>
)}
<nav className="flex items-center gap-1 ml-4">
<a
href="#"
className="px-3 py-1.5 text-sm text-gray-600 rounded-md hover:bg-gray-100 transition-colors"
>
Projects
</a>
<a
href="#"
className="px-3 py-1.5 text-sm text-gray-600 rounded-md hover:bg-gray-100 transition-colors"
>
Members
</a>
<a
href="#"
className="px-3 py-1.5 text-sm text-gray-600 rounded-md hover:bg-gray-100 transition-colors"
>
Settings
</a>
</nav>
</header>
);
}
Custom Domain Setup
// app/api/workspaces/[id]/custom-domain/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { domain } = await req.json();
// Validate domain format
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$/.test(domain)) {
return NextResponse.json({ error: "Invalid domain format" }, { status: 400 });
}
// Check uniqueness
const existing = await prisma.workspace.findFirst({
where: { customDomain: domain, id: { not: params.id } },
});
if (existing) {
return NextResponse.json({ error: "Domain already in use" }, { status: 409 });
}
// Add domain to Vercel project via API
if (process.env.VERCEL_PROJECT_ID) {
await addDomainToVercel(domain);
}
await prisma.workspace.update({
where: { id: params.id },
data: {
customDomain: domain,
customDomainVerified: false,
customDomainAddedAt: new Date(),
},
});
return NextResponse.json({
domain,
cnameTarget: `cname.yourapp.com`,
instructions: `Add a CNAME record pointing ${domain} to cname.yourapp.com`,
});
}
async function addDomainToVercel(domain: string): Promise<void> {
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 }),
}
);
}
DNS Configuration Guide
For Vercel deployments, instruct tenants:
Subdomain (app.acme.com):
Type: CNAME
Name: app
Value: cname.vercel-dns.com
TTL: 3600
Apex domain (acme.com):
Type: A
Name: @
Value: 76.76.21.21 (Vercel's IP)
TTL: 3600
Wildcard verification (your domain): Add to your Vercel project:
- Domain:
*.yourapp.com - Vercel auto-provisions SSL via Let's Encrypt wildcard cert
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic subdomain routing (middleware only) | 1 dev | 1โ2 days | $300โ600 |
| Subdomain + per-tenant config + theming | 1 dev | 1 week | $1,500โ3,000 |
| Full system (+ custom domains + Vercel API) | 1โ2 devs | 2โ3 weeks | $4,000โ9,000 |
| Enterprise (SSO per tenant, SAML, custom billing) | 2โ3 devs | 4โ8 weeks | $15,000โ35,000 |
See Also
- SaaS Multi-Workspace Architecture
- Next.js Middleware Authentication Patterns
- Next.js Internationalization with next-intl
- SaaS Role-Based Access Control
- PostgreSQL Row-Level Security for Multi-Tenancy
Working With Viprasol
Subdomain multi-tenancy looks simple until you hit custom domains, SSL provisioning, edge caching, and per-tenant auth. Our team has built SaaS platforms with subdomain tenancy, Vercel custom domain APIs, and per-tenant theming systems where customers can upload their own logo and set brand colours.
What we deliver:
- Next.js middleware for subdomain and custom domain routing
- Tenant config system with React cache() deduplication
- Dynamic CSS variable theming without client-side flash
- Custom domain setup flow with Vercel API integration
- DNS setup documentation for your customers
Talk to our team about your multi-tenant architecture โ
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.