Template config

The single typed club.config.ts: identity, theme, locales, environment markers, features and the composed domains.

2026-11-07

defineClubConfig

The site is described by one file — apps/web/club.config.ts — built with defineClubConfig() from @v4/template-config and validated at build time. It is the one authoritative description of the site: no second JSON file, no untyped NEXT_PUBLIC_* variables read ad hoc, no settings duplicated between the code and Contentful.

The principle is the committed-artefact + drift-gate pattern already used for @v4/tokens and @v4/model, applied to the app shell: committed site facts, validated at build, cross-gated against the things they must agree with.

The shape

This repo's file, with the presence-gated feature blocks elided (they are covered in Env markers):

// apps/web/club.config.ts (excerpt)
import { defineClubConfig, env } from "@v4/template-config";

export default defineClubConfig({
  identity: {
    name: "EuroLeague Basketball",
    shortName: "EuroLeague",
    crest: { light: "/crest.svg", dark: "/crest.svg" },
    titleTemplate: "%s | EuroLeague Basketball",
  },
  // The club tokens are wired in src/theme/tokens.css.ts, not here.
  theme: { tokens: {}, chrome: { colourWay: "default" } },
  fonts: {
    heading: { family: "Staff Wide", src: "/fonts/staff-wide/StaffWide-Black.otf" },
    body: { family: "Bw Modelica", src: "/fonts/bw-modelica/BwModelica-Regular.otf" },
  },
  locales: { supported: ["en-US"], default: "en-US", routing: "segment" },
  domains: ["content", "video", "basketball"],
  sections: {
    default: "euroleague",
    capabilities: { corporate: { sport: false, video: false, switcher: false } },
  },
  video: {
    apiBaseUrl: env.public("NEXT_PUBLIC_VIDEO_API_BASE_URL"),
    brightcoveAccountId: env.public("NEXT_PUBLIC_BRIGHTCOVE_ACCOUNT_ID"),
    landingSlug: "home",
  },
  competitionEngine: {
    baseUrl: env.server("COMPETITION_ENGINE_BASE_URL", { default: "http://localhost:8080" }),
    competitions: { euroleague: "E", eurocup: "U", nextgen: "J" },
  },
  content: {
    spaceId: env.server("CONTENTFUL_SPACE_ID"),
    environment: env.server("CONTENTFUL_ENVIRONMENT", { default: "develop" }),
    delivery: env.server("CONTENTFUL_ACCESS_TOKEN"),
    preview: env.server("CONTENTFUL_PREVIEW_TOKEN", { default: "" }),
  },
  seo: {
    baseUrl: env.public("NEXT_PUBLIC_SITE_BASE_URL", { default: "http://localhost:3000" }),
    allowRobots: process.env["NEXT_PUBLIC_ROBOTS_ALLOW"] === "true",
  },
  images: { defaultProvider: "scoreplay", scoreplay: {} },
  hosting: { platform: "flightcontrol" as const, ...hostingRedis },
  features: {
    ...featuresStories,
    ...featuresLivestream,
    ...featuresCalendar,
    ...featuresAnalytics,
    ...featuresConsent,
    ...featuresAuth,
  },
});

What each layer holds

The boundaries are hard, and they are the rule of thumb to apply to any new value: credentials → env, site facts → config, seasonal/editorial → CMS.

  • Config is committed site facts: ids, vocabulary, policy, switches.
  • Env is secrets and per-deploy values only, reached exclusively through the env.public() / env.server() markers (see Env markers).
  • Contentful owns editor-controlled values that change without a deploy: the current season, sponsor slots, nav structure, all copy.

identity.titleTemplate must contain %s; identity.crest paths are app-relative; theme.colourSetDefault is one of the eight colour sets, defaulting to base1. hosting.platform (flightcontrol | vercel | node) is the deploy switch (this site: flightcontrol). The other blocks, all in packages/template-config/src/schema.ts:

  • theme.chrome.colourWay — default | inverse: the way the site header and its chrome are drawn.
  • locales.routing — segment puts every page under its locale's URL segment (/en/…, this site) and compose mounts the shims under app/[locale]/; none (the default) serves unprefixed addresses.
  • images — defaultProvider (contentful | scoreplay | cloudinary, default contentful) and one block per provider (false disables it).
  • competitionEngine — the engine's baseUrl (a server marker) and competitions, each section slug mapped to its engine code (E, U, J).
  • video — the platform apiBaseUrl, brightcoveAccountId and landingSlug (default home).
  • routes — the route vocabulary, sitemap settings (pageCap, perRoute change frequency and priority) and staticParamsBudget, how many pages each route prerenders at build.
  • brands — sub-brands served by host pattern (id, hostPattern, chrome).

sections (optional) says what the CMS cannot about the competition sections: default is the default competition's slug, so the app can send an old address naming it to the page's one address in a single redirect, and capabilities is what each section HAS, by slug — sport (the data areas), video (a video home), switcher (a row in the competition switcher). A section not named has everything. pnpm compose writes both to src/lib/sections.generated.ts (see Competition sections).

What the config generates

Values that would otherwise be hand-maintained in parallel — and drift separately from the integrations they mirror — are computed from the config instead:

  • deriveImageHosts(config) produces the next.config images.remotePatterns allowlist. Enabling a video provider admits its thumbnail host; disabling a feature removes it, so the allowlist can no longer rot. Contentful's asset hosts are added whenever content is set, the Competition Engine's crest and headshot CDNs whenever competitionEngine is, and each enabled image provider's CDN (Scoreplay's here).
  • deriveRobots(config) produces robots.ts from seo.baseUrl and seo.allowRobots.

Both are exported from @v4/template-config and take the resolved config, so there is one source for each derived surface rather than a hand-edited list with a "keep these three files in sync" comment.

The result is frozen and typed

defineClubConfig() returns a frozen ResolvedClubConfig. Its public/env markers are already resolved (see the next section), its locales are narrowed back to the model's locale union, and its auth seam is normalised to an adapter or false. The app consumes this object directly for metadata, theme, locales and robots.

Env markers

The config file names the env vars it needs without holding their values. env.public() and env.server() are the only sanctioned path to process.env, and defineClubConfig() resolves every marker at parse time — failing the build with every missing name at once, rather than letting scattered process.env reads each crash a different component at runtime.

import { env } from "@v4/template-config";

seo: { baseUrl: env.public("NEXT_PUBLIC_SITE_BASE_URL", { default: "http://localhost:3000" }) },
content: {
  spaceId: env.server("CONTENTFUL_SPACE_ID"),
  environment: env.server("CONTENTFUL_ENVIRONMENT", { default: "develop" }),
  delivery: env.server("CONTENTFUL_ACCESS_TOKEN"),
  preview: env.server("CONTENTFUL_PREVIEW_TOKEN", { default: "" }),
},

The public / server split is structural

The two markers enforce the boundary between what may reach the browser and what must not (a secret under a NEXT_PUBLIC_ prefix ships to every visitor):

  • env.public(name) is a client-safe value. It must be prefixed NEXT_PUBLIC_ — the marker throws otherwise, because that prefix is what Next.js inlines into the client bundle and it is the only place a public var belongs.
  • env.server(name) is a server-only secret. It must not be prefixed NEXT_PUBLIC_ — the marker throws if it is, because a NEXT_PUBLIC_ var is inlined into the client and cannot be a secret.

The guard runs at the marker call, so a mis-declared var is a build error at the point of declaration, not a leak discovered in production.

How markers resolve

resolveEnvMarkers walks the whole config tree and resolves each marker in place:

  • A public marker resolves to a plain string.
  • A server marker resolves to a lazy ServerSecret accessor whose .value getter throws in a browser context. A leaked secret therefore fails loudly at first read on the client, not silently at bundle time.
  • Any marker whose var is unset (and has no default) is collected into a missing list. If that list is non-empty, defineClubConfig() throws a ClubConfigError naming every missing var, sorted and de-duplicated:
club.config.ts requires env vars that are not set:
  CONTENTFUL_ACCESS_TOKEN
  CONTENTFUL_SPACE_ID

A marker may carry a fallback: env.server("CONTENTFUL_ENVIRONMENT", { default: "develop" }). The fallback covers an unset var only: resolveEnvMarkers reads readEnv(node.name) ?? node.defaultValue, which keeps a var that is set but empty as "", so a field that refuses a blank fails the build instead of taking the default. Leave such a var unset, never empty — the WSC stage, NEXT_PUBLIC_WSC_ENVIRONMENT, is the live case.

Where markers are allowed

The DefineClubConfigInput type accepts markers only on the fields that can legitimately carry a secret or per-deploy value (packages/template-config/src/define.ts): content.spaceId / environment / delivery / preview, seo.baseUrl, football.dataApi (a template domain's field, unused here), video.apiBaseUrl / brightcoveAccountId, competitionEngine.baseUrl, hosting.redis, features.stories.apiKey / environment, features.livestream.apiKey / realm / baseUrl / environment / watchUrl / loginUrl / accessUrl, features.calendar.apiKey, features.analytics.containerId and features.consent.domainScriptId. The feature values are public markers only — a server marker is refused at parse. Because the ServerSecret object survives zod parsing intact (the schema uses z.custom, not z.object, which would strip the guarded getter), a resolved server secret keeps its browser-throwing .value all the way to the consumer.

features.stories is the first feature block to take markers. The zod input of a { … } | false arm knows only the resolved value, so DefineClubConfigInput re-declares that arm — the pattern to copy for the next feature that needs a per-deploy value. The WSC key refuses a server marker because it is public by nature: the SDK writes it into the page, so there is no secret to keep. features.livestream (the ELTV player) is the second, and refuses a server marker for the Vesper key for the same reason — the browser sends it as x-api-key. features.calendar (ECAL), features.analytics (GTM) and features.consent (OneTrust) followed, all public by nature.

A direct process.env read is sanctioned only as a presence check, for an optional feature that is off while its var is unset or empty. Such a feature is declared conditionally: a raw check in club.config.ts chooses {} or the block, and the value itself still goes through its marker. Use it only where an unconditional marker would make the var mandatory. apps/web/club.config.ts declares six blocks this way:

Block Switched on by Spread into
hostingRedis CACHE_REDIS_URL hosting
featuresStories NEXT_PUBLIC_WSC_API_KEY features
featuresLivestream NEXT_PUBLIC_VESPER_API_KEY features
featuresCalendar NEXT_PUBLIC_ECAL_API_KEY features
featuresAnalytics NEXT_PUBLIC_GTM_CONTAINER_ID features
featuresConsent NEXT_PUBLIC_ONETRUST_DOMAIN_SCRIPT_ID features

Inside featuresLivestream the two optional URLs — NEXT_PUBLIC_ELTV_LOGIN_URL and NEXT_PUBLIC_ELTV_ACCESS_URL — are spread in the same way, each only when set. A seventh, featuresAuth, comes from readCortexSsoSettings(process.env) (@v4/cortex-sso), on when CORTEX_SSO_BASE_URL is set: features.auth is an adapter seam whose options are code, so its values are read directly rather than through markers, and a missing companion variable fails by name.

The net effect is mechanical: unknown and dead env vars become build errors, and no value reaches process.env except through a marker the config declared.

Features & domains

Two related lists live in the config: features (the runtime integrations the site turns on) and domains (the model/route slices it is composed of). They are distinct — a feature is a provider and its credentials; a domain is a slice of the content model and its routes — and a cross-gate keeps them honest.

Feature modules, not booleans

An integration is never switched on by being imported: every feature is a discriminated union, where false means genuinely absent — routes unmounted, providers not rendered, image hosts not allowed, bundles not imported:

features: {
  search:        { provider: "algolia", appId: "…", indexName: "…", publicKey: "…" } | false,
  liveMatch:     { realtime: "ably", subscribeKey: "…" } | false,
  broadcasters:  { provider: "pulselive", logoBaseUrl: "https://…" } | false,
  gamification:  { crest: "/crest.svg", shareTargets: ["x"] } | false,
  video:         { provider: "youtube" | "endeavor" | "streamamg" } | false,
  analytics:     { provider: "ga4", measurementId: "…" }
                 | { provider: "gtm", containerId: "GTM-…" } | false,
  consent:       { provider: "onetrust", domainScriptId: "<uuid>[-test]",
                   groups?: { strictlyNecessary?, analytics?, functional?, marketing? } } | false,
  errorTracking: { provider: "sentry", dsn: "…" } | false,
  ads:           { provider: "gpt" } | false,
  ticker:        {} | false,
  stories:       { provider: "wsc", apiKey: "…", environment?: "prod" | "uat",
                   allowHorizontalStories?: boolean } | false,
  livestream:    { provider: "vesper", apiKey: "…", realm: "dce.…", baseUrl: "https://…",
                   environment: "prod" | "stag", watchUrl: "https://…",
                   loginUrl?: "https://…", accessUrl?: "https://…/{gameCode}" } | false,
  calendar:      { provider: "ecal", apiKey: "…",
                   displays: Record<sectionSlug, { widgetId, category, teamCategory?,
                                                   teamNames?, logo? }> } | false,
  auth:          { adapter: AuthAdapter } | false,
}

On this site six of these are wired, each on only when its variable is set: stories (WSC), livestream (ELTV), calendar (ECAL), analytics (gtm), consent (onetrust) and auth (Cortex). search, liveMatch, broadcasters, gamification, video, errorTracking, ads and ticker are schema slots with no consumer in the app: nothing reads them, so setting one changes nothing. Live game data is not liveMatch — it comes from the Competition Engine, polled over HTTP (see Basketball and the Competition Engine).

Each block defaults to false, so a config that names none turns none on. analytics, consent and errorTracking are first-class because every public site needs them. Euro League uses gtm and onetrust (@v4/analytics): both ids are public by nature and are checked at build, because each is written into the page; groups names the site's OneTrust group for each of the tracking plan's consent categories where it is not OneTrust's default.

The auth adapter — an extension point, not an enum

One feature is not a value at all. features.auth is { adapter: AuthAdapter } | false, where AuthAdapter is an interface the app consumes. No config value can express "a hand-rolled OIDC provider on a different NextAuth major", so the seam is code the site supplies — validated structurally (a non-empty string id), not by zod. This is one of the first-class forks that config provably cannot carry; the others (transform overrides, chrome composition, match-centre arrangement) get their own seams rather than hope.

This site supplies the Cortex adapter: { adapter: { id: "cortex", options: CortexSsoSettings } } from @v4/cortex-sso, when CORTEX_SSO_BASE_URL is set. It backs the header's sign-in and the Code of Conduct report.

Domains — the composed bolt-ons

domains is a plain list of the model/route domains the site is composed of:

domains: ["content", "video", "basketball"],

Baseline domains (core, content) are implicit — the site's editorial core is always there, so there is nothing to decide; listing content, as this config does, is harmless. Implied domains (sport-common) join via the sports that require them. The schema only shapes the list as strings; the semantic validation (unknown, quarantined or implied names) happens in compose-club through the model's own resolver — see Domains & bolt-ons.

The cross-gate: a feature cannot outrun its model

defineClubConfig() cross-checks that an enabled feature which has a model domain has that domain in the domains list. The mapping is one-directional by design:

// enforced in defineClubConfig
const FEATURE_DOMAINS = [
  ["video", "video"],
  ["gamification", "gamification"],
  ["ads", "ads"],
];

Enable features.video without "video" in domains and the build fails:

features.video is enabled but the 'video' domain is not in domains —
a feature cannot outrun its model composition

stories is not gated although it has a content type: wscWidget is this site's own type (created by migrations/club/0009), not a bolt-on domain's, so there is no domain for it to require. livestream (the ELTV player) is not gated either: it is this site's own integration, reads no Contentful data, and is unrelated to the video domain's liveStream content type and to features.video. calendar, analytics, consent and auth have no model domain, so nothing gates them.

The reverse is legal: a domain can be composed without a matching feature module (basketball has none — its integration is the top-level competitionEngine block). Two other cross-gates run in the same pass — the default locale must be one of locales.supported, and locales.supported is checked at the type level against the model's DICTIONARY_LOCALES, so a locale the model does not carry is a compile error before the runtime check ever runs.