Template config
The single typed club.config.ts: identity, theme, locales, environment markers, features and the composed domains.
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—segmentputs every page under its locale's URL segment (/en/…, this site) andcomposemounts the shims underapp/[locale]/;none(the default) serves unprefixed addresses.images—defaultProvider(contentful|scoreplay|cloudinary, defaultcontentful) and one block per provider (falsedisables it).competitionEngine— the engine'sbaseUrl(a server marker) andcompetitions, each section slug mapped to its engine code (E,U,J).video— the platformapiBaseUrl,brightcoveAccountIdandlandingSlug(defaulthome).routes— the routevocabulary,sitemapsettings (pageCap,perRoutechange frequency and priority) andstaticParamsBudget, 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 thenext.configimages.remotePatternsallowlist. 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 whenevercontentis set, the Competition Engine's crest and headshot CDNs whenevercompetitionEngineis, and each enabled image provider's CDN (Scoreplay's here).deriveRobots(config)producesrobots.tsfromseo.baseUrlandseo.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 prefixedNEXT_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 prefixedNEXT_PUBLIC_— the marker throws if it is, because aNEXT_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
ServerSecretaccessor whose.valuegetter 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 amissinglist. If that list is non-empty,defineClubConfig()throws aClubConfigErrornaming 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.