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.

2026-11-07

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/web owns 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-app and @v4/basketball-app, mounted into the app by generated shims. See App & rendering.
  • The design system. @v4/ghost-core, @v4/ghost-content, @v4/ghost-video and @v4/ghost-basketball are 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/model versions 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). With core (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.

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's core and content slices and their migrations, apps/storybook, apps/e2e, scripts/, the .claude rules, skills, agents and references, and CLAUDE.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.ts included), @v4/tokens-euro-league, the basketball packages, the integration packages listed above, the model's migrations/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.