← Back to index | ← 06 Scoring + calibration | 08a MMC analytics →
How the public quiz Next.js app (moneyquiz-app) resolves the tenant, applies theming, threads brand identity through client components, and serves the 5 formats. As of v1.6.6 (2026-05-19).
App structure
moneyquiz-app/
├── src/app/
│ ├── layout.tsx ← Server component: resolves tenant, mounts theme + brand providers
│ ├── page.tsx ← Money Mirror (root + /mirror via re-export)
│ ├── mirror/page.tsx
│ ├── quiz/page.tsx ← Server-side tenant-router (redirects to tenant's Likert)
│ ├── game/page.tsx
│ ├── deal/page.tsx
│ ├── realm/page.tsx
│ ├── chooser/page.tsx ← 5-card vertical selector
│ ├── booked/page.tsx
│ ├── lp/[personaSlug]/page.tsx
│ └── api/
│ ├── quiz/results/route.ts ← Mirror + Game finalize + email + webhook
│ ├── deal/results/route.ts ← Deal + Realm finalize + webhook
│ ├── send-results/route.ts ← Email sender (per-tenant Resend config)
│ ├── voice/route.ts
│ └── ...
├── src/components/
│ ├── TenantThemeProvider.tsx ← Server component, emits CSS variable overrides
│ ├── TenantBrandContext.tsx ← Client context for coach identity strings
│ ├── deal/BookingCTA.tsx ← Consumes TenantBrandContext
│ ├── realm/... ← (Realm UI; moved from deal/realm/ in C9)
│ └── ...
├── src/lib/
│ ├── tenant-config.ts ← Tenant resolution + brand/framework fetch + webhook helper
│ ├── score-analyser.ts ← analyseScores + applyArchetypeCalibration (TS)
│ ├── archetypes.ts ← ARCHETYPES + COMBINATION_MODIFIERS constants
│ └── ...
└── src/middleware.ts ← CORS + iframe permission headers
Tenant resolution
Three resolution functions in tenant-config.ts:
| Function | Inputs | Used by |
|---|---|---|
resolveTenant(req: Request) | URL query + Host header | API routes |
resolveTenantFromHeaders() | Host header only (via next/headers) | Server components (layout.tsx) |
Priority: ?tenant= query (when accessible) > Host header lookup > DEFAULT_TENANT env.
Host map (hardcoded today):
const HOST_TENANT_MAP: Record<string, string> = {
"quiz.thesynergygroup.ch": "mmc",
"assess.thesynergygroup.ch": "mmc",
// future: "quiz.coachpilot.ch": "coachpilot",
};
Note the asymmetry: quiz.thesynergygroup.ch maps to MMC, NOT TSG. This is historical — TSG hosts the platform but the default visitor experience there is MMC's Money Mirror funnel. To visit as TSG-the-tenant, use ?tenant=tsg.
Layout-time theming + brand
src/app/layout.tsx is a server component. On every render:
export default async function RootLayout({ children }) {
const tenantId = await resolveTenantFromHeaders();
const brand = await loadTenantBrandById(tenantId);
return (
<html lang="en" data-tenant={tenantId} className="...">
<body className="...">
<TenantThemeProvider tenantId={tenantId} />
<TenantBrandProvider value={brand}>
{children}
</TenantBrandProvider>
<footer>...</footer>
</body>
</html>
);
}
Two providers, two responsibilities:
TenantThemeProvider — CSS variable overrides
src/components/TenantThemeProvider.tsx is a server component. It fetches the tenant brand (deduplicated against the layout-level fetch via Next.js fetch cache) and emits a <style> block:
<style data-tenant-theme="mmc">:root {
--color-brown: #401405;
--color-terracotta: #401405;
--color-gold: #B39F65;
--color-cream: #EDEBDE;
--color-background: #EDEBDE;
--color-sage: #919C82;
--font-heading: Newsreader, Georgia, serif;
--font-newsreader: Newsreader, Georgia, serif;
--font-sans: Mulish, system-ui, sans-serif;
--font-mulish: Mulish, system-ui, sans-serif;
}</style>
Tailwind v4 reads these CSS custom properties via the @theme inline block in globals.css. Utilities like bg-brown, text-gold, font-heading automatically pick up the new values. No class changes needed.
Default behaviour: when a tenant has no brand JSONB (TSG/coachpilot/HSP today), the provider returns null (no style tag emitted) and the platform default palette (Ilana's burgundy + gold) remains. This is a zero-risk migration default per the project's brand decision.
Iframe opt-out: brand.theme_mode === "inherit" returns null regardless of values. Used for tenants whose parent frame supplies chrome and shouldn't be overridden.
TenantBrandProvider — React context for client components
src/components/TenantBrandContext.tsx is the client-side bridge. It exposes the server-fetched brand object via React Context.
Two hooks:
useTenantBrand(): TenantBrand | null // raw brand object
useCoachIdentity(): { // coach identity with Ilana fallbacks
coachPhoto: string,
coachName: string,
coachTitle: string,
}
BookingCTA.tsx consumes useCoachIdentity():
const ctx = useCoachIdentity();
const coachPhoto = coachPhotoProp ?? ctx.coachPhoto;
const coachName = coachNameProp ?? ctx.coachName;
const coachTitle = coachTitleProp ?? ctx.coachTitle;
Priority: explicit prop (used by dashboard preview) > context (production) > Ilana defaults.
API routes — the configuration consumption points
| Route | Tenant resolution | What it reads |
|---|---|---|
/api/quiz/results | resolveTenant(req) (URL) | loadEngineConfig + loadTenantBrand |
/api/deal/results | resolveTenant(req) | loadTenantBrand |
/api/send-results | (passed through from caller) | loadTenantBrand for sender + signature |
/api/voice | resolveTenant(req) | providers.voice from resolved framework |
/api/admin/evidence-report | (auth required) | loadEngineConfig |
/api/calibrate | resolveTenant(req) | engine config — local TS calibration (debug helper) |
Each calls loadResolvedFramework and/or loadTenantBrand on every request. The Next.js fetch({next: {revalidate: 30}}) cache deduplicates these within the 30 s window.
The completion lifecycle (Mirror + Game example)
Player completes session (Mirror or Game)
│
▼
POST /api/quiz/results
Body: { scores, userName, userEmail, sessionId, gameMode? }
│
▼
Step 1: Parallel fetch
├─ loadEngineConfig(req) → engine config from /resolved
└─ loadTenantBrand(req) → brand row
│
▼
Step 2: analyseScores(scores, engineConfig)
├─ applyArchetypeCalibration → calibrated scores
├─ per-archetype severity assessment
├─ combination detection
└─ urgency + coaching frame
│
▼
Step 3: LLM narrative (Claude Sonnet)
│
▼
Step 4: Fire-and-forget
├─ fireArchetypeWebhook(brand, payload)
│ └─ POSTs to brand.archetype_scores_webhook (no-op if NULL)
├─ send-results email (per-tenant Resend config)
├─ saveClient to Redis CRM
└─ Coach notification email
│
▼
Return result to client
The webhook + coach notification + CRM save all happen non-blocking. If any fail, the player still gets their result.
Format routes
Each format has a page.tsx under src/app/<slug>/:
| Path | Component | Notes |
|---|---|---|
/ | page.tsx | Money Mirror landing |
/mirror | mirror/page.tsx | Re-exports root |
/chooser | chooser/page.tsx | 5-card vertical selector |
/quiz | quiz/page.tsx | Server-side tenant-router. Requires explicit ?tenant= to redirect; else marketing fallback page. |
/game | game/page.tsx | Money Game intro + start |
/game/[avatar] | game/[avatar]/page.tsx | Active game session |
/deal | deal/page.tsx | Money Deal start |
/deal/[avatar] | deal/[avatar]/page.tsx | Active deal session |
/realm | realm/page.tsx | Money Realm intro + start |
/booked | booked/page.tsx | Post-booking thanks page |
/lp/[personaSlug] | lp/[personaSlug]/page.tsx | Persona-specific landing |
A 308 redirect in next.config.ts catches /deal/realm[/:path*] → /realm[/:path*] for stale bookmarks (C2 leftover).
Middleware — CORS + iframe permission
src/middleware.ts sets:
Content-Security-Policy: frame-ancestors 'self' https://mindfulmoneycoaching.online https://*.thesynergygroup.ch ...X-Frame-Optionscleared (CSP supersedes)- CORS for cross-origin API calls
This is what allows MMC's WordPress to iframe quiz.thesynergygroup.ch/?tenant=mmc without browser blocking.
When adding a new white-label tenant's parent domain, edit the CSP frame-ancestors list.
Environment variables consumed
| Variable | Purpose | Default |
|---|---|---|
ASSESS_GATEWAY_URL | Gateway base URL | http://localhost:8142/assess (CRASHES in production if not set) |
DEFAULT_TENANT | Fallback tenant id when host doesn't match | mmc |
RESEND_API_KEY | Resend (transactional email) | none (skips coach notifications if missing) |
ANTHROPIC_API_KEY | Claude API | required for results narrative |
Critical (incident 2026-05-17): ASSESS_GATEWAY_URL MUST be set on the assess/dashboard pod. When it was missing, fetches silently fell back to localhost:8142, returned null engineConfig, and calibration silently no-op'd. Now mandatory. See feedback in reference_money_quiz_design_strategy.md.
Common pitfalls
useCoachIdentity()returns null defaults outside<TenantBrandProvider>. All client components that need coach identity MUST be rendered inside the layout's provider. Test pages outside the layout (rare) need to wrap manually.- The theme provider only themes by Host, NOT by
?tenant=.headers()in server components doesn't expose URL. Sendingquiz.tsg/?tenant=tsgrenders as MMC-themed (host) with TSG data (page-level routes see the query). This is intentional — production hosts are stable; query overrides are for testing. - CSS
:rootoverrides only work if they ship AFTER globals.css cascade. TenantThemeProvider mounts inside<body>, after head's stylesheet links. If you move it elsewhere, override may not win. - Webhook + email fire-and-forget. Errors are logged but not surfaced to the player. Check pod logs to debug undeliveries.
Next
→ 08a MMC analytics — Quiz Leads dashboard, A/B Tests, archetype-scores receiver.