B2B SaaS & Enterprise PlatformsBuilding White-Label Customer Portals: Dynamic Domain Routing and Theme Isolation with Next.js
Strategic White PaperIndustry: B2B SaaS & Enterprise PlatformsPractice: Full-Stack Web Development

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.

D

Danisur Rahman

Verified Practice Lead
Lead Systems Architect•Oct 9, 2026•11 min read
Building White-Label Customer Portals: Dynamic Domain Routing and Theme Isolation with Next.js

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:

  1. 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.
  2. 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.
  3. 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.

sh
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:

LayerResponsibilityTypical tooling
Domain and TLSMap hostnames to tenants, issue certificatesVercel Domains API, Cloudflare for SaaS, or Caddy on-demand TLS
RoutingResolve host to tenant, rewrite to internal treeNext.js middleware.ts
RenderingApply theme, scope data and caches by tenantServer 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.

ts
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 /_sites guard, anyone can request portal.acme.com/_sites/other-tenant/invoices and 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 builds headers from the request and then sets the values, a client-supplied x-tenant-id is 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:

sql
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:

  1. Source of truth in Postgres.
  2. A write-through projection into Edge Config or a KV store keyed by host.
  3. 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:

  1. Tenant admin enters portal.acme.com in settings.
  2. Platform creates a pending row and displays the DNS instruction: a CNAME to your edge target (apex domains need ALIAS/ANAME or flattened CNAME, or an A record to fixed IPs where the provider supports them).
  3. A background job polls DNS and the provider API until the record resolves and a certificate is issued.
  4. On success, the row flips to verified, the registry projection updates, and the portal is live.

Your options for certificate automation:

ApproachStrengthsTrade-offs
Vercel Domains API (when hosting on Vercel)Attach domains programmatically, certificates managed for youTied 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 frontExtra moving part and a vendor-specific API
Self-hosted Caddy with on-demand TLSFull control, portableYou must implement the ask endpoint to authorize which hostnames may get certificates
If you self-host with Caddy, the 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:

sh
{
  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:

css
/* app/globals.css */
:root {
  --brand-primary: 221 83% 53%;
  --brand-primary-fg: 0 0% 100%;
  --surface: 0 0% 100%;
  --radius: 0.5rem;
}

ts
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:

tsx
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, with generateMetadata returning 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_cache and fetch keys. Always include the tenant identifier in the key parts and tags.

ts
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) =&gt;
  unstable_cache(
    400 font-semibold">async () =&gt; 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 params or headers(), 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 from params (set by the rewrite) for pages you want cached, and use the header only where dynamism is acceptable.
  • CDN Vary and 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#

RiskControl
Host header spoofingExact-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 ownershipRequire DNS verification before activation
Stored XSS via theme or contentConstrained theme schema; no raw CSS or HTML injection
Cross-tenant sessionsHost-only cookies; tenant ID inside the signed session and checked on every request
Content Security PolicyPer-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.

DimensionDeployment per tenantSingle multi-tenant deployment
Deploys per release cycle1201
Config surfaces to audit120 environment sets1 environment + registry rows
New customer go-liveProvisioning ticket, hours to daysRegistry insert + DNS verification
Blast radius of a bad releaseOne tenant at a time, but N rollouts to repairAll tenants, mitigated by canary and instant rollback
Noisy-neighbor riskIsolatedShared; needs rate limits and per-tenant quotas
The trade is explicit: you exchange operational multiplication for shared-fate risk, then buy back safety with progressive delivery, per-tenant rate limiting, and feature flags keyed by tenant. For most mid-market B2B portals we consider that a good trade. For tenants with contractual single-tenant isolation requirements, a hybrid is realistic: the same codebase deployed to a dedicated environment for a few regulated customers, registry-driven for everyone else.

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.

Senior Systems Architecture Advisory
Available for Q4 Advisory & Audits

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.

Direct WhatsApp ChannelFastest

Chat with an Architect

Direct access during working hours. Outline your stack, latency constraints, or bottlenecks for rapid feedback.

Open WhatsApp Discussion
Formal Architecture Brief1-Day SLA

Request an Engineering Review

Share your specification, RFP, or architecture diagram. We reply with technical scope and feasibility.

Mutual NDA Protected1 Working Day Response100% IP & Code Ownership
Explore All Contact Channels
D

Danisur Rahman

Practice Lead

Lead Systems Architect • KNetwork Advisory

Schedule Advisory Briefing

Advises enterprise technical leadership, CTOs, and heads of engineering on enterprise modernization, cloud migration governance, high-concurrency ledger design, and sovereign artificial intelligence compliance.