How this site is built
The Euro League site's stack, its layers and packages, the three domains it is composed of, and which code the template owns.
What this site is built on
The Euro League website is one pnpm + Turborepo monorepo: the Next.js app that is
deployed, the design system it is drawn with, the Contentful model as code, and one package per
outside service it talks to. Packages depend on each other with workspace:*, so nothing is
published to npm and a change to a shared component and the page that uses it are one pull
request and one CI run.
It serves news and editorial content from Contentful, a video section from the Stadion video platform, and the basketball competition data — teams, players, coaches, games and the Game Centre, standings and stats — read from ELB's Competition Engine, for every competition (EuroLeague, EuroCup, NextGen and the Corporate site) in one site.
The core stack
| Layer | Choice |
|---|---|
| Runtime | Next 16 / React 19 (React Server Components) / TypeScript 5.9 |
| Styling | vanilla-extract (typed CSS-in-TS, built at build time — no runtime CSS) |
| Tokens | Token Studio tokens.json → @v4/token-parser → @v4/tokens |
| Content | Contentful, accessed only through @v4/model (GraphQL fragments) |
| Sport data | ELB's Competition Engine, accessed only through @v4/competition-engine |
| Monorepo | pnpm workspaces + Turborepo; internal packages use workspace:* |
| Quality gates | ESLint 9 strict type-aware (no any, zero warnings), Prettier, vitest |
| Hosting | Flightcontrol, with Redis for the shared cache and Cloudflare in front |
The layers
- A thin app.
apps/webowns the chrome (header, footer, navigation), the theme, the model client, the API routes and the middleware — and almost no page bodies. It is configured by one typed file,apps/web/club.config.ts: identity, theme, locales, competition sections, the composed domains and which integrations are on. See Template config and Web app. - Route packages, one per domain. The pages themselves live in
@v4/content-app,@v4/video-appand@v4/basketball-app, mounted into the app by generated shims. See App & rendering. - The design system.
@v4/ghost-core,@v4/ghost-content,@v4/ghost-videoand@v4/ghost-basketballare the Ghost 2.0 design system rebuilt in vanilla-extract with typed token contracts, re-skinned to ELB's brand by@v4/tokens-euro-league. The design source is ELB's own Figma files — forks of Ghost that preserve its node ids — plus the basketball file drawn fresh (see Building against ELB's Figma forks). See Component system and Token pipeline. - The content model as code.
@v4/modelversions the Contentful model — migrations, a vendored schema, generated types, curated fragments and transforms — so the model travels with the code that reads it. See Content model. - One package per integration. The Competition Engine (
@v4/competition-engine), the video platform (@v4/media), the image providers (@v4/image-*, Scoreplay's CDN by default), WSC Sports clips (@v4/wsc), EuroLeague TV (@v4/eltv), add to calendar (@v4/ecal), Google Tag Manager and OneTrust (@v4/analytics), EuroLeague ID sign-in (@v4/cortex-sso) and the In-Arena Code of Conduct report (@v4/code-of-conduct). Each is the only place its SDK or script is loaded. See Club integrations and Media and video.
The domains this site is composed of
A domain is a slice of the site with its own content types, route package and components.
This site composes three, declared in club.config.ts and recorded in
packages/model/composition.json:
content— articles, news listings, content pages, the home page and the content blocks (@v4/content-app,@v4/ghost-content). Withcore(navigation, site settings, links, the dictionary) it is always present.video— the video landing, detail, collection, playlist and category pages (@v4/video-app,@v4/ghost-video,@v4/media). See The video section.basketball— the competition data areas (@v4/basketball-app,@v4/ghost-basketball,@v4/competition-engine). It is this project's own domain: the template has no basketball. See Basketball and the Competition Engine.
On top of them sit the competition sections: EuroLeague at the root, EuroCup and NextGen under their slugs, and Corporate, each address carrying its locale. How the domains are wired together is on Domains & bolt-ons.
Where to read next
The token pipeline (Token pipeline), the component system (Component system) and the content model (Content model) each have their own page; this page is the map they hang from. The layout of the repository is in Monorepo & packages.
Key principles
Four principles run through the whole architecture. They are worth stating once, because most of the detailed rules elsewhere in this documentation are consequences of them.
Composed from one config
The site is not assembled by hand. apps/web/club.config.ts declares which domains the site
is composed of — content, video and basketball — and the composition generator
(pnpm compose) reads it and writes the route shims, the model composition
(packages/model/composition.json) and the generated locale and section files. The output is
committed and drift-gated in CI, so the config and what is wired can never disagree. A domain
the site does not compose has no trace in its schema or routes. See
Domains & bolt-ons.
Typed tokens
Every design token is a typed TypeScript path, not a stringly-typed CSS custom
property. Token Studio tokens.json is the authored source; @v4/token-parser
compiles it to generated defaults and per-component contracts in @v4/tokens;
components wire those contracts through buildGlobalTheme and
registerComponentTheme. The generated defaults define the contract — the
shape of the override type is derived from them — so if @v4/tokens-euro-league
lags a structure change it fails to compile, attributed to the exact token path. A
mistyped or missing token is a compile error, never a silently wrong colour at runtime.
Expand/contract
The content model evolves under an expand/contract discipline. Every model
migration must be backwards-compatible with currently deployed code: additive,
optional changes ship first; destructive operations (deleting a field, making an
existing field required, deleting a type) ship one release after the code
that stopped depending on them. lint-migrations.mjs enforces this — a
destructive migration without a // contract: marker fails the PR.
The same currency discipline applies to documentation and tooling catalogues: a
change that adds, removes, or repurposes a .claude/ skill, agent, or rule
updates the team-facing catalogue in the same commit, because a stale catalogue
is worse than none.
Composition fidelity
If a component composes children in Figma, the code composes the real child components — never a re-implementation or an inline HTML/CSS imitation. A missing child is a build gap to be filled by building that child first, not a licence for placeholder markup. The same holds up the stack: a page template composes real content blocks, and the route factory composes real page bodies.
The reward is that overrides flow: because the real child owns its own theme
contract and CSS variables, ELB's re-skin or a per-instance tokens override
reaches every level of the composition. A faked child would freeze its values
beyond every override.
Design-owned layout and motion
Two further rules come from ELB's design team, and both are enforced in code review and e2e:
- The undrawn rungs have an answer. ELB draws most components at 360 and 1440 only; the tablet (768–1023) and laptop (1024–1439) bands follow the grid and bind the page measure. See Layout rungs and Responsive rungs.
- Motion is specified, not invented. Every animation names a pattern from
ELB's motion spec, takes its values from
motion.*tokens, and resolves to its final state at once under reduced motion. See Motion.
Template code and our code
This repo was generated from the Publishing Hub v4 template (stadionHQ/publishing-hub):
that is where the @v4/* package names, the Ghost design system, the model spine and most of
the tooling came from. The template is still maintained, and its changes reach this repo as
/sync-upstream pull requests (.claude/manifest.json templateCommit records the template
commit last synced; it is aba41ea71).
That splits the code into two kinds, and the difference changes what you may do:
- Template-owned — the shared design-system packages (
ghost-core,ghost-content,ghost-video,tokens,token-parser),content-app,video-app,media, the image packages,template-config, the model'scoreandcontentslices and their migrations,apps/storybook,apps/e2e,scripts/, the.clauderules, skills, agents and references, andCLAUDE.md. A sync PR patches these: the next sync applies the template's changes on top of any edit made here with a three-way merge, and leaves a conflict where both changed the same lines. A fix every site built from the template should have is better made in the template. - Ours —
apps/web(club.config.tsincluded),@v4/tokens-euro-league, the basketball packages, the integration packages listed above, the model'smigrations/club/, this docs app, and the ticket tooling's board configuration. No sync ever touches them.
The exact lists, and what to do with a sync PR, are in Take template updates.