Building White-Label Customer Portals: Dynamic Domain Routing and Theme Isolation with Next.js
An architectural deep dive into serving many branded customer portals from one Next.js 14 deployment: host-based tenant resolution in middleware, automated custom-domain TLS, server-rendered CSS-variable themes, and tenant-safe caching.

For CTOs and engineering leaders of B2B platforms, the white-label portal request arrives sooner or later, usually attached to a large deal: "Our customers should log in at portal.acme-logistics.com, see our logo and colors, and never know your product exists." Saying yes in a sales call is easy. Delivering it for 5 tenants, then 50, then 500 without forking the codebase is an architectural problem.
Three failure modes show up repeatedly when teams treat white-labeling as a theming task instead of a platform capability:
- Per-tenant deployments. One deployment per brand feels safe, but every release becomes N releases. Operational cost grows linearly with tenants and drift between versions becomes a support burden.
- Cross-tenant leakage. A shared cache key, a shared cookie scope, or a server component that reads the wrong tenant's theme turns a branding bug into a data-exposure incident that enterprise security reviews will not forgive.
- Brittle domain onboarding. If attaching a custom domain needs an engineer, a DNS ticket and a redeploy, sales will route around engineering.
This article describes an architecture we consider sound for a single Next.js 14 (App Router) deployment serving many branded portals: host-based tenant resolution in middleware, internal rewrites to a tenant-scoped route tree, automated TLS for custom domains, CSS-variable theme isolation rendered on the server, and tenant-aware caching. Code is representative rather than copy-paste production code, and any numbers are worked examples with stated assumptions, not measurements.
The Target Architecture#
The core idea: the browser sees portal.acme-logistics.com/invoices, while the application internally handles /_sites/acme/invoices. A single deployment, a single build, one route tree parameterized by tenant.
Browser Edge / Middleware App Router
------- ----------------- ----------
portal.acme.com/invoices -> 1. read Host header
2. normalize + lookup tenant -> app/_sites/[tenant]/invoices/page.tsx
3. rewrite (URL unchanged) layout.tsx injects theme tokens
4. set x-tenant-id header data layer scopes queries by tenant
Three layers carry the design:
| Layer | Responsibility | Typical tooling |
|---|---|---|
| Domain and TLS | Map hostnames to tenants, issue certificates | Vercel Domains API, Cloudflare for SaaS, or Caddy on-demand TLS |
| Routing | Resolve host to tenant, rewrite to internal tree | Next.js middleware.ts |
| Rendering | Apply theme, scope data and caches by tenant | Server Components, CSS variables, unstable_cache |
Dynamic Domain Routing in Next.js Middleware#
Resolving the tenant from the Host header#
Middleware runs on the Edge runtime, so it cannot use Node database drivers. Resolve the tenant through something edge-friendly: Vercel Edge Config, a Redis/KV REST endpoint, or a small internal lookup API with aggressive caching. The code below assumes a lookupTenantByHost helper that does one of these.
400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// middleware.ts
400 font-semibold">import { NextRequest, NextResponse } 400 font-semibold">from 400 font-semibold">class="text-emerald-300">"next/server";
400 font-semibold">import { lookupTenantByHost } 400 font-semibold">from 400 font-semibold">class="text-emerald-300">"@/lib/tenant-registry";
400 font-semibold">const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN!; 400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// e.g. 400 font-semibold">class="text-emerald-300">"platform.example"
400 font-semibold">export 400 font-semibold">const config = {
matcher: [
400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// skip Next internals and 400 font-semibold">static assets
400 font-semibold">class="text-emerald-300">"/((?!api/|_next/|_static/|favicon.ico|robots.txt).*)",
],
};
400 font-semibold">export 400 font-semibold">async 400 font-semibold">function middleware(req: NextRequest) {
400 font-semibold">const url = req.nextUrl.clone();
400 font-semibold">const host = (req.headers.get(400 font-semibold">class="text-emerald-300">"host") ?? 400 font-semibold">class="text-emerald-300">"").toLowerCase().split(400 font-semibold">class="text-emerald-300">":")[0];
400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// Marketing site and app shell live on the root domain
400 font-semibold">if (host === ROOT_DOMAIN || host === 400 font-semibold">class="text-emerald-300">`www.${ROOT_DOMAIN}`) {
400 font-semibold">return NextResponse.next();
}
400 font-semibold">const tenant = 400 font-semibold">await lookupTenantByHost(host);
400 font-semibold">if (!tenant || tenant.status !== 400 font-semibold">class="text-emerald-300">"active") {
url.pathname = 400 font-semibold">class="text-emerald-300">"/_sites/not-found";
400 font-semibold">return NextResponse.rewrite(url, { status: 404 });
}
400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// Prevent users 400 font-semibold">from addressing the internal tree directly
400 font-semibold">if (url.pathname.startsWith(400 font-semibold">class="text-emerald-300">"/_sites")) {
400 font-semibold">return 400 font-semibold">new NextResponse(400">null, { status: 404 });
}
400 font-semibold">const headers = 400 font-semibold">new Headers(req.headers);
headers.set(400 font-semibold">class="text-emerald-300">"x-tenant-id", tenant.id);
headers.set(400 font-semibold">class="text-emerald-300">"x-tenant-slug", tenant.slug);
url.pathname = 400 font-semibold">class="text-emerald-300">`/_sites/${tenant.slug}${url.pathname}`;
400 font-semibold">return NextResponse.rewrite(url, { request: { headers } });
}
Several details matter here:
- Normalize the host. Strip ports, lowercase, and decide whether
www.variants are aliases or separate entries. Inconsistent normalization is the most common source of "works on one domain but not another" tickets. - Treat the Host header as untrusted input. The lookup must be an exact match against a registry you control. Never construct a tenant from a substring or regex of the header.
- Block the internal prefix. Without the
/_sitesguard, anyone can requestportal.acme.com/_sites/other-tenant/invoicesand the rewrite logic would be bypassed. Even if page-level checks exist, defense in depth is cheap here. - Overwrite, never trust, inbound
x-tenant-*headers. Because the code buildsheadersfrom the request and then sets the values, a client-suppliedx-tenant-idis replaced. On the root-domain branch, also strip those headers so a visitor cannot spoof them into server components.
Subdomains and custom domains in one registry#
Most platforms offer both acme.platform.example (instant, wildcard DNS) and portal.acme.com (customer-owned). Model both as rows in one table rather than special-casing subdomains in code:
400 font-semibold">CREATE 400 font-semibold">TABLE tenant_domains (
host text PRIMARY KEY, -- normalized, lowercase
tenant_id uuid NOT NULL REFERENCES tenants(id),
kind text NOT NULL CHECK (kind IN (400 font-semibold">class="text-emerald-300">'subdomain',400 font-semibold">class="text-emerald-300">'custom')),
verified_at timestamptz,
tls_status text NOT NULL DEFAULT 400 font-semibold">class="text-emerald-300">'pending',
is_primary 400">boolean NOT NULL DEFAULT 400">false
);
The platform-owned subdomain is auto-verified at creation. A custom domain only becomes routable after verification. The lookupTenantByHost function should refuse unverified hosts, which closes the door on someone claiming a domain they do not control.
Keeping the registry lookup off the hot path#
Every request pays for tenant resolution, so treat it as a latency budget item. A pragmatic layout:
- Source of truth in Postgres.
- A write-through projection into Edge Config or a KV store keyed by host.
- Short in-memory caching in middleware (seconds), plus explicit invalidation when a domain changes.
Custom Domain Onboarding and TLS#
The routing layer is the easy half. The operational half is getting a certificate for a hostname you do not control until the customer adds a DNS record.
A typical self-serve flow:
- Tenant admin enters
portal.acme.comin settings. - Platform creates a
pendingrow and displays the DNS instruction: aCNAMEto your edge target (apex domains needALIAS/ANAMEor flattened CNAME, or an A record to fixed IPs where the provider supports them). - A background job polls DNS and the provider API until the record resolves and a certificate is issued.
- On success, the row flips to
verified, the registry projection updates, and the portal is live.
Your options for certificate automation:
| Approach | Strengths | Trade-offs |
|---|---|---|
| Vercel Domains API (when hosting on Vercel) | Attach domains programmatically, certificates managed for you | Tied to platform limits and pricing; verify current per-project domain limits |
| Cloudflare for SaaS (custom hostnames) | Strong at large tenant counts, fallback origin model, WAF in front | Extra moving part and a vendor-specific API |
| Self-hosted Caddy with on-demand TLS | Full control, portable | You must implement the ask endpoint to authorize which hostnames may get certificates |
ask endpoint is not optional. Without it, anyone can point a domain at your IP and trigger certificate issuance, burning through Let's Encrypt rate limits:
{
on_demand_tls {
ask http:400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">//tenant-registry.internal/tls-check
}
}
https:400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// {
tls {
on_demand
}
reverse_proxy nextjs:3000
}
The tls-check endpoint returns 200 only when the queried domain exists in tenant_domains as a verified row.
Theme Isolation: Server-Rendered Tokens, Not Client-Side Swaps#
Routing gets the request to the right tenant. Theme isolation ensures the right brand appears with no flash of default styling and no chance of one tenant's styles reaching another's page.
Design tokens as CSS variables#
Define your component library against semantic tokens, never raw brand colors:
/* app/globals.css */
:root {
--brand-primary: 221 83% 53%;
--brand-primary-fg: 0 0% 100%;
--surface: 0 0% 100%;
--radius: 0.5rem;
}
400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// tailwind.config.ts (excerpt)
theme: {
extend: {
colors: {
primary: 400 font-semibold">class="text-emerald-300">"hsl(400 font-semibold">var(--brand-primary) / <alpha-value>)",
400 font-semibold">class="text-emerald-300">"primary-fg": 400 font-semibold">class="text-emerald-300">"hsl(400 font-semibold">var(--brand-primary-fg) / <alpha-value>)",
surface: 400 font-semibold">class="text-emerald-300">"hsl(400 font-semibold">var(--surface) / <alpha-value>)",
},
borderRadius: { DEFAULT: 400 font-semibold">class="text-emerald-300">"400 font-semibold">var(--radius)" },
},
}
Injecting the tenant theme in the tenant layout#
The tenant layout is a Server Component. It fetches the theme and emits variables on the root element, so the first byte of HTML already carries the brand:
400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic">// app/_sites/[tenant]/layout.tsx
400 font-semibold">import { getTenantTheme } 400 font-semibold">from 400 font-semibold">class="text-emerald-300">"@/lib/theme";
400 font-semibold">import { notFound } 400 font-semibold">from 400 font-semibold">class="text-emerald-300">"next/navigation";
400 font-semibold">export 400 font-semibold">default 400 font-semibold">async 400 font-semibold">function TenantLayout({
children,
params,
}: {
children: React.ReactNode;
params: { tenant: 400">string };
}) {
400 font-semibold">const theme = 400 font-semibold">await getTenantTheme(params.tenant);
400 font-semibold">if (!theme) notFound();
400 font-semibold">const style = {
400 font-semibold">class="text-emerald-300">"--brand-primary": theme.primaryHsl,
400 font-semibold">class="text-emerald-300">"--brand-primary-fg": theme.primaryFgHsl,
400 font-semibold">class="text-emerald-300">"--radius": theme.radius,
} as React.CSSProperties;
400 font-semibold">return (
<div data-tenant={params.tenant} style={style} className=400 font-semibold">class="text-emerald-300">"min-h-screen bg-surface">
{children}
</div>
);
}
Rules that keep this safe:
- Validate tokens on write. Theme values come from customer admins. Accept only constrained formats (HSL triplets, a fixed radius set, allow-listed font families) and reject anything else. Never interpolate free-form CSS into a
<style>tag; that is a stored-XSS and CSS-exfiltration vector. - Avoid per-tenant CSS files in a shared bundle. Compiling one stylesheet per tenant at build time makes builds scale with tenants. Runtime variables keep one build for all tenants.
- Assets by tenant prefix. Logos and favicons belong in object storage under
tenants/<id>/, served through the CDN, withgenerateMetadatareturning the tenant favicon and title.
Caching Without Leaking Between Tenants#
This is where most real incidents originate. The Next.js data cache and full route cache key on URL and fetch arguments. Because rewrites keep the browser URL identical across tenants (for example /invoices) but the internal path differs (/_sites/acme/invoices), the route cache is naturally separated by internal path. Danger appears in code that bypasses that separation:
unstable_cacheandfetchkeys. Always include the tenant identifier in the key parts and tags.
400 font-semibold">import { unstable_cache } 400 font-semibold">from 400 font-semibold">class="text-emerald-300">"next/cache";
400 font-semibold">export 400 font-semibold">const getTenantTheme = (slug: 400">string) =>
unstable_cache(
400 font-semibold">async () => db.themes.findBySlug(slug),
[400 font-semibold">class="text-emerald-300">"tenant-theme", slug],
{ tags: [400 font-semibold">class="text-emerald-300">`tenant:${slug}`], revalidate: 300 }
)();
- Invalidate by tag. When an admin saves a new theme, call
revalidateTag(tenant:${slug})from the server action. Only that tenant's entries are purged. - Never read the tenant from global module state. Per-request tenant identity must come from
paramsorheaders(), not a module-level variable, because server instances handle concurrent requests for different tenants. - Be deliberate with
headers(). Reading it opts a route into dynamic rendering. Prefer deriving the tenant fromparams(set by the rewrite) for pages you want cached, and use the header only where dynamism is acceptable. - CDN
Varyand cache keys. If a CDN sits in front, the cache key must include the Host. Most do by default, but a custom rule that strips it for "performance" is a classic leak.
Authentication scoping#
Cookies are host-scoped by default, which is a feature: a session cookie set on portal.acme.com is not sent to portal.other.com. Do not set a Domain=.platform.example cookie for tenant sessions, since that would expose sessions across every subdomain tenant. In the data layer, enforce the tenant at the query level (for example Postgres Row-Level Security with SET LOCAL app.tenant_id) so a routing bug cannot become a data bug.
Security Checklist for White-Label Surfaces#
| Risk | Control |
|---|---|
| Host header spoofing | Exact-match registry lookup; reject unknown hosts with 404 |
| Subdomain takeover (dangling DNS) | Remove registry rows and provider hostnames on tenant offboarding; periodically audit |
| Domain claiming without ownership | Require DNS verification before activation |
| Stored XSS via theme or content | Constrained theme schema; no raw CSS or HTML injection |
| Cross-tenant sessions | Host-only cookies; tenant ID inside the signed session and checked on every request |
| Content Security Policy | Per-tenant CSP built from allow-listed asset origins |
A Worked Example: What Changes at Scale#
Assume a platform with 120 branded portals, a release cadence of two deployments per week, and a team that previously ran one deployment per tenant. The assumptions below are illustrative; substitute your own numbers.
| Dimension | Deployment per tenant | Single multi-tenant deployment |
|---|---|---|
| Deploys per release cycle | 120 | 1 |
| Config surfaces to audit | 120 environment sets | 1 environment + registry rows |
| New customer go-live | Provisioning ticket, hours to days | Registry insert + DNS verification |
| Blast radius of a bad release | One tenant at a time, but N rollouts to repair | All tenants, mitigated by canary and instant rollback |
| Noisy-neighbor risk | Isolated | Shared; needs rate limits and per-tenant quotas |
Decision Framework for Engineering Leaders#
Use these questions to decide how far to take the pattern:
- How many portals within 18 months? Under ten, a simpler approach (subdomains only, theme in config) may be enough. Beyond fifty, automate domain onboarding fully.
- Do customers require their own domain? If yes, choose the TLS automation path first; it dictates your hosting constraints.
- Is contractual isolation required? Plan the hybrid model early so the same artifact can deploy dedicated or shared.
- How much customization will you allow? Tokens, logo and copy are cheap. Layout changes, custom components and custom CSS multiply testing cost and security exposure. Draw the line in the product spec, not in individual sales deals.
- Who owns the registry? Treat it as production infrastructure with audit logs, access control and invalidation guarantees.
Conclusion#
White-label portals are a platform capability, not a theming exercise. The pattern that holds up is consistent: a verified host registry, Edge middleware that resolves and rewrites without trusting the client, automated certificate issuance gated by ownership checks, CSS-variable themes validated on write and rendered on the server, and caches and queries that carry the tenant identity in every key. Get those five right and a new branded portal becomes a data operation instead of an engineering project.
If you are planning a white-label rollout, or untangling an existing per-tenant deployment model, the knetwork.live engineering team is available for an architecture consultation covering domain automation, tenant isolation review, and a migration plan sized to your release process.
Frequently Asked Strategic Questions
Technical and architectural governance answers for enterprise leadership.
Planning a high-concurrency overhaul or critical migration?
Move from theoretical documentation to production verification. You talk directly with the principal architects who build, benchmark, and deploy these systems—never a business development rep or account manager.
Chat with an Architect
Direct access during working hours. Outline your stack, latency constraints, or bottlenecks for rapid feedback.
Request an Engineering Review
Share your specification, RFP, or architecture diagram. We reply with technical scope and feasibility.
Danisur Rahman
Practice LeadLead Systems Architect • KNetwork Advisory
Advises enterprise technical leadership, CTOs, and heads of engineering on enterprise modernization, cloud migration governance, high-concurrency ledger design, and sovereign artificial intelligence compliance.
Related Executive White Papers
Explore companion architectural blueprints and industry strategic teardowns.
Consolidating Multi-Store Analytics: Collapsing 10+ Storefront Sheets into a Single Executive BI Engine
A case study and architecture blueprint for collapsing 10+ regional storefront spreadsheets into a single real-time executive BI engine: ClickHouse OLAP, currency normalization, and true Contribution Margin 3 (CM3) modeling.
Dynamic Price Optimization Engines: Real-Time Competitor Scraping and Price Recalculation Pipelines
An engineering specification for real-time dynamic pricing: camouflaged competitor scraping at 1.2M SKUs/hr, Kafka event buffering, microeconomic elasticity modeling, and cryptographic margin floor guardrails.