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

FunctionInputsUsed by
resolveTenant(req: Request)URL query + Host headerAPI 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

RouteTenant resolutionWhat it reads
/api/quiz/resultsresolveTenant(req) (URL)loadEngineConfig + loadTenantBrand
/api/deal/resultsresolveTenant(req)loadTenantBrand
/api/send-results(passed through from caller)loadTenantBrand for sender + signature
/api/voiceresolveTenant(req)providers.voice from resolved framework
/api/admin/evidence-report(auth required)loadEngineConfig
/api/calibrateresolveTenant(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>/:

PathComponentNotes
/page.tsxMoney Mirror landing
/mirrormirror/page.tsxRe-exports root
/chooserchooser/page.tsx5-card vertical selector
/quizquiz/page.tsxServer-side tenant-router. Requires explicit ?tenant= to redirect; else marketing fallback page.
/gamegame/page.tsxMoney Game intro + start
/game/[avatar]game/[avatar]/page.tsxActive game session
/dealdeal/page.tsxMoney Deal start
/deal/[avatar]deal/[avatar]/page.tsxActive deal session
/realmrealm/page.tsxMoney Realm intro + start
/bookedbooked/page.tsxPost-booking thanks page
/lp/[personaSlug]lp/[personaSlug]/page.tsxPersona-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-Options cleared (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

VariablePurposeDefault
ASSESS_GATEWAY_URLGateway base URLhttp://localhost:8142/assess (CRASHES in production if not set)
DEFAULT_TENANTFallback tenant id when host doesn't matchmmc
RESEND_API_KEYResend (transactional email)none (skips coach notifications if missing)
ANTHROPIC_API_KEYClaude APIrequired 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

  1. 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.
  2. The theme provider only themes by Host, NOT by ?tenant=. headers() in server components doesn't expose URL. Sending quiz.tsg/?tenant=tsg renders 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.
  3. CSS :root overrides 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.
  4. 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.