Building a White-Label SaaS Platform: Tenant Theming, Custom Domains, and Dynamic Configuration in Next.js
How to architect a white-label SaaS platform in Next.js where each tenant gets isolated branding, a custom domain, and dynamic configuration without forking your codebase.
Most teams build white-labeling as an afterthought. They add a brandColor column to the tenants table, inject it as an inline style somewhere, and call it done. Three months later, they have inline styles scattered across 40 components, a config system that can’t be tested, and a customer asking why their logo still shows up in email notifications.
White-labeling done properly is an architectural decision, not a feature flag. It affects routing, asset serving, configuration loading, caching, and build pipelines. Get it right at the start and each new tenant is a database row. Get it wrong and every new tenant is a deployment.
This article covers the four load-bearing pieces: tenant resolution from hostname, CSS variable theming that actually composes with Tailwind, dynamic configuration loading with a sensible cache model, and custom domain routing through wildcard DNS. Each section includes the production gotchas that bite you after you ship.
Tenant Resolution
Everything starts with knowing which tenant owns the current request. In Next.js, that means middleware.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
export interface TenantContext {
tenantId: string;
slug: string;
customDomain: boolean;
}
const PLATFORM_DOMAIN = process.env.PLATFORM_DOMAIN ?? "app.example.com";
export async function middleware(req: NextRequest): Promise<NextResponse> {
const host = req.headers.get("host") ?? "";
const tenant = await resolveTenant(host);
if (!tenant) {
return NextResponse.redirect(new URL("/404", req.url));
}
const res = NextResponse.next();
res.headers.set("x-tenant-id", tenant.tenantId);
res.headers.set("x-tenant-slug", tenant.slug);
return res;
}
async function resolveTenant(host: string): Promise<TenantContext | null> {
// Strip port for local dev
const hostname = host.split(":")[0];
// Custom domain: acme.com
if (!hostname.endsWith(`.${PLATFORM_DOMAIN}`) && hostname !== PLATFORM_DOMAIN) {
return resolveByCustomDomain(hostname);
}
// Subdomain: acme.app.example.com
const subdomain = hostname.replace(`.${PLATFORM_DOMAIN}`, "");
if (!subdomain || subdomain === "www") return null;
return resolveBySlug(subdomain);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
The header forwarding (x-tenant-id, x-tenant-slug) is how you get tenant context into Server Components and API routes without re-running the resolution logic on every request. Read those headers downstream, not the hostname.
Do not call your database directly from middleware. Middleware runs on every request including static assets if you configure the matcher too broadly. Use a lightweight edge-compatible lookup: a KV store, a small in-memory cache backed by a periodic refresh, or a simple HTTP call to a dedicated tenant resolution endpoint that sits behind your CDN. The resolution result should be cached at the edge for at least 60 seconds per tenant.
// lib/tenant-cache.ts
type CacheEntry = { tenant: TenantContext | null; expiresAt: number };
const cache = new Map<string, CacheEntry>();
const TTL_MS = 60_000;
export async function resolveTenantCached(
hostname: string
): Promise<TenantContext | null> {
const now = Date.now();
const entry = cache.get(hostname);
if (entry && entry.expiresAt > now) {
return entry.tenant;
}
const tenant = await fetchTenantFromApi(hostname);
cache.set(hostname, { tenant, expiresAt: now + TTL_MS });
return tenant;
}
For Cloudflare Workers-based middleware, swap the in-memory map for KV with a 60-second TTL. The pattern is identical; only the storage backend changes.
Tenant-Aware Theming with CSS Variables and Tailwind
The right model is: define a fixed set of design tokens as CSS custom properties, let tenants configure values for those tokens, apply them at the root element, and build your entire UI in terms of those tokens. Never reference raw hex values in component code.
// types/tenant-theme.ts
export interface TenantTheme {
colorPrimary: string; // hex, e.g. "#6366f1"
colorPrimaryFg: string; // foreground on primary background
colorSecondary: string;
colorSecondaryFg: string;
colorBackground: string;
colorSurface: string;
colorBorder: string;
colorText: string;
colorTextMuted: string;
fontFamily: string; // CSS font-family value
borderRadius: string; // e.g. "0.5rem"
logoUrl: string;
faviconUrl: string;
}
In your root layout, inject these as CSS variables:
// app/layout.tsx
import { headers } from "next/headers";
import { getTenantTheme } from "@/lib/tenant";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const headersList = headers();
const tenantId = headersList.get("x-tenant-id");
const theme = tenantId ? await getTenantTheme(tenantId) : defaultTheme;
const cssVars = buildCssVars(theme);
return (
<html lang="en">
<head>
<link rel="icon" href={theme.faviconUrl} />
</head>
<body style={cssVars as React.CSSProperties}>{children}</body>
</html>
);
}
function buildCssVars(theme: TenantTheme): Record<string, string> {
return {
"--color-primary": theme.colorPrimary,
"--color-primary-fg": theme.colorPrimaryFg,
"--color-secondary": theme.colorSecondary,
"--color-secondary-fg": theme.colorSecondaryFg,
"--color-background": theme.colorBackground,
"--color-surface": theme.colorSurface,
"--color-border": theme.colorBorder,
"--color-text": theme.colorText,
"--color-text-muted": theme.colorTextMuted,
"--font-family": theme.fontFamily,
"--border-radius": theme.borderRadius,
};
}
Extend your Tailwind config to consume those variables:
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
primary: "var(--color-primary)",
"primary-fg": "var(--color-primary-fg)",
secondary: "var(--color-secondary)",
"secondary-fg": "var(--color-secondary-fg)",
background: "var(--color-background)",
surface: "var(--color-surface)",
border: "var(--color-border)",
text: "var(--color-text)",
"text-muted": "var(--color-text-muted)",
},
fontFamily: {
sans: ["var(--font-family)", "system-ui", "sans-serif"],
},
borderRadius: {
DEFAULT: "var(--border-radius)",
},
},
},
};
export default config;
Now bg-primary text-primary-fg works everywhere without per-tenant stylesheets, build steps, or any JavaScript-side theming logic. The CSS variable bridge is what makes this compose cleanly: Tailwind generates the utility classes once at build time; the runtime values come from the variables set per request.
The failure mode to avoid: putting tenant theme values into className strings or inline styles on individual components. That approach starts seeming fine and ends with you maintaining two theming systems and neither working fully.
Dynamic Configuration Loading
Theming is one dimension. Tenants also need feature flags, integration credentials, copy overrides, and plan-gated capabilities. That is configuration, and it needs a different model from theming.
// types/tenant-config.ts
export interface TenantConfig {
tenantId: string;
slug: string;
name: string;
theme: TenantTheme;
features: {
analyticsEnabled: boolean;
customReportsEnabled: boolean;
ssoEnabled: boolean;
apiAccessEnabled: boolean;
whitelabelEmailEnabled: boolean;
};
plan: "starter" | "growth" | "enterprise";
emailFromName: string;
emailFromAddress: string;
supportUrl: string | null;
customCss: string | null; // escape hatch, used sparingly
}
Serve this from a single endpoint your app calls internally:
// lib/tenant.ts
import { cache } from "react";
// React cache() deduplicates calls within a single render tree.
// For the same tenantId, getTenantConfig runs once per request, not once per component.
export const getTenantConfig = cache(async (tenantId: string): Promise<TenantConfig> => {
const res = await fetch(
`${process.env.INTERNAL_API_URL}/tenants/${tenantId}/config`,
{
next: {
revalidate: 300, // ISR: revalidate every 5 minutes
tags: [`tenant:${tenantId}`],
},
}
);
if (!res.ok) {
throw new Error(`Failed to fetch tenant config for ${tenantId}: ${res.status}`);
}
return res.json() as Promise<TenantConfig>;
});
export const getTenantTheme = async (tenantId: string): Promise<TenantTheme> => {
const config = await getTenantConfig(tenantId);
return config.theme;
};
The next: { revalidate: 300, tags: [...] } fetch option means configuration is cached at the Next.js data cache layer and can be purged immediately when a tenant updates their settings:
// app/api/webhooks/tenant-updated/route.ts
import { revalidateTag } from "next/cache";
import type { NextRequest } from "next/server";
export async function POST(req: NextRequest): Promise<Response> {
const body = await req.json() as { tenantId: string; secret: string };
if (body.secret !== process.env.WEBHOOK_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
revalidateTag(`tenant:${body.tenantId}`);
return new Response("OK");
}
When a tenant saves their branding settings in the admin panel, your backend fires this webhook and the next request to any page under that tenant picks up the new config. No deployment required. The 5-minute TTL is a fallback for cases where the webhook misses; it is not the primary invalidation path.
The customCss escape hatch deserves a note. Some enterprise tenants need pixel-level control that your token system cannot express: custom font loading from their CDN, animations, density adjustments. Build an escape hatch from day one rather than bolting it on later. Serve custom CSS through a separate <style> tag in the layout and sanitize it server-side before storing it. The sanitization matters: stored CSS can include expression() in IE and @import to arbitrary URLs in modern browsers, both of which you do not want in your app.
Custom Domain Routing
Subdomain routing (acme.app.example.com) is straightforward. Custom domains (acme.com pointing to your platform) require infrastructure work.
The DNS side: tenants add a CNAME record pointing their domain to a platform-controlled hostname (proxy.example.com). You operate a wildcard TLS certificate for *.example.com for subdomains and provision per-domain TLS certificates for custom domains automatically via ACME (Let’s Encrypt or a CDN that handles this for you).
Cloudflare makes this manageable through its SSL for SaaS product. You create a Cloudflare zone for your platform, and for each custom domain a tenant adds, you call the API to register it as a custom hostname:
// lib/custom-domains.ts
export interface CustomDomainRegistration {
hostname: string;
tenantId: string;
status: "pending" | "active" | "error";
verificationErrors: string[];
}
export async function registerCustomDomain(
hostname: string,
tenantId: string
): Promise<CustomDomainRegistration> {
const res = await fetch(
`https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/custom_hostnames`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.CF_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
hostname,
ssl: {
method: "http",
type: "dv",
settings: { min_tls_version: "1.2" },
},
custom_metadata: { tenantId },
}),
}
);
const data = await res.json() as { result: { id: string; status: string; ssl: { validation_errors: Array<{ message: string }> } } };
await db.customDomains.upsert({
where: { hostname },
create: { hostname, tenantId, cfHostnameId: data.result.id, status: "pending" },
update: { cfHostnameId: data.result.id, status: "pending" },
});
return {
hostname,
tenantId,
status: "pending",
verificationErrors: [],
};
}
Once the tenant has pointed their DNS and Cloudflare has issued the certificate, your middleware resolves the custom domain to the correct tenant the same way it resolves subdomains: look up the hostname in your database, return the tenant context. The request reaches your Next.js app with a Host header of acme.com and middleware handles it identically to acme.app.example.com.
Poll the Cloudflare API or use webhooks to update the status field in your customDomains table. Show the current status in your admin UI so tenants can debug their own DNS configuration rather than opening support tickets.
Tradeoffs
| Dimension | Approach | Tradeoff |
|---|---|---|
| Theme storage | CSS variables via SSR | Zero client JS for theming, but theme changes require a server re-render |
| Config cache | ISR + tag revalidation | Near-instant updates on demand, stale up to TTL on missed webhooks |
| Tenant resolution | Middleware + header forwarding | Single resolution per request, but middleware must be fast or it taxes all routes |
| Custom domains | CDN-level (SSL for SaaS) | Automatic cert provisioning, but adds dependency on CDN for TLS |
| Custom CSS escape hatch | Server-stored + sanitized | Flexible for enterprise tenants, but requires sanitization pipeline to be correct |
| Config structure | Single typed interface | Easy to reason about, but schema migrations affect all tenants simultaneously |
Production Considerations
Cache stampede on cold starts. If your platform restarts or a new region spins up, every tenant’s first request hits the origin simultaneously. Warm the most active tenants on startup with a background job that pre-fetches their configs. You do not need to warm all tenants: the top 20% by request volume covers most of the impact.
Tenant isolation in error monitoring. Tag all errors with tenantId in your observability stack. When a tenant reports a problem, you want to filter to their requests specifically rather than searching through noise. This also lets you track per-tenant error rates and detect tenants with misconfigured integrations before they escalate.
Schema migrations for TenantConfig. When you add a new field to TenantConfig, every existing tenant row needs a sensible default. Enforce this at the database schema level, not in application code. If the database returns a row without a field, a TypeScript type assertion in your API does not protect you; the missing field causes a runtime error downstream. Use a Zod schema at the API boundary and log parse failures explicitly.
Asset serving for tenant logos and favicons. Serve tenant assets from a path that includes the tenant ID to ensure correct CDN cache scoping: /assets/tenants/{tenantId}/logo.png. Without the tenant prefix, a CDN edge node may cache one tenant’s logo and serve it to another tenant in the same geographic region if the cache key doesn’t include the full path. This is not theoretical; it happens.
SSO and auth with custom domains. OAuth and SAML callbacks are tied to specific redirect URIs. When a tenant uses a custom domain, their auth callback URL changes from app.example.com/auth/callback to acme.com/auth/callback. Your identity provider needs both URIs registered, or you need a proxy that normalizes the callback back to a canonical platform URL before completing the auth flow. Plan for this before you build the auth system, not after.
Testing tenant isolation. Write tests that spin up two tenant contexts and assert that config, theming, and data from tenant A never appears in tenant B’s requests. This is tedious to set up once and cheap to run forever. Without it, you will ship a cross-tenant data leak within a year, which at enterprise scale is a security incident.
Closing
The core insight is that white-labeling is a routing and configuration problem, not a styling problem. If you get the tenant resolution and configuration loading right, theming becomes a thin CSS variable layer that composes with your existing design system. If you approach it as “just change the colors,” you end up with a system that requires a deployment per tenant and breaks in edge cases you did not anticipate.
The architectural bet is on the CSS variable bridge and the typed configuration interface. Both give you a contract: the set of things tenants can customize is explicit and finite, the rendering pipeline is unchanged, and every new tenant is an INSERT rather than a branch.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.