Competition sections

How one site holds EuroLeague, EuroCup, NextGen and Corporate: the addresses, the chrome that follows them and the themes.

2026-11-07

Sections and addresses

The site is one website holding every competition — EuroLeague (the default), EuroCup, NextGen and the Corporate site — rather than a site each (ELB Competition URL and CMS Structure, Confluence page 4040785923). Each competition is a Competition entry in Contentful (club/0030), and its Slug is its section's address segment; club/0035 gives the content types their competition and Site Settings its default. This page is the home for how sections work: the addresses, the modules that decide them, the chrome that follows them, the locale segment, and the per-competition themes. The whole site drawn out, page by page, is the Site map.

Every address carries its locale

Every page address starts with its locale's segment — /en, /en/news, /en/teams/eurocup — because the site's one locale, en-US, is routed by segment. How that is wired is in The locale segment.

The addresses

What Address Decided by
The default competition's homepage /en Site Settings → Default Competition; the Home Page with that Competition (or, until one is set, the Home Page with none)
Another competition's homepage /en/eurocup the Home Page whose Competition is EuroCup
A default-competition content page /en/about, /en/about/history the page's Full Path
Another competition's content page /en/eurocup/about its TOP-LEVEL parent's Competition
An article /en/news/<slug> always — one address for every competition
The default news listing /en/news Site Settings → Default News Landing Page
A competition's listing /en/news/eurocup a listing with that Competition and no category; with none, the GENERATED listing — every competition has one
A category's listing /en/news/category/interviews a listing with that Category and no competition; it takes over the category's own page at that address
Both /en/news/euroleague/category/interviews a listing with both; with none, the GENERATED listing
A listing filtering by neither /en/news/<slug> its own Slug
A competition's video home /en/video, /en/video/eurocup /video is the default competition's (the landing); any other's is the Video Landing Page whose slug is the competition's, else a temporary page. Not for a section without video (Corporate)
A competition's teams, players, coaches, games, standings, stats /en/teams/euroleague, /en/games/eurocup the DEFAULT competition is at its slug here; a bare /en/teams redirects to it. @v4/basketball-app, over the Competition Engine; the coaches LIST is still a temporary page. Not for a section without data areas (Corporate)
A list's filters /en/games/eurocup/season/2025-26/round/12, /en/players/euroleague/team/<team>/page/2 LIST_FILTERS: games by season then round; teams, standings and stats by season; players by season, team and page; coaches none. The season is its alias or code; the current season is the bare address
A list's view or tab /en/standings/eurocup/format, /en/stats/euroleague/players the standings' Format view (format), the stats' Players and Teams tabs (players, teams) after the season's address
A team, player, coach or game /en/teams/<team>, /en/games/<game> one address for every competition it plays in, and nothing after it but the pages declared under it
A team's roster /en/teams/<team>/roster a page under the team's (ENTITY_PAGES, the site map review, 21 Oct 2026)

A page belongs to a section by its address (/en/eurocup/…, /en/news/eurocup, /en/teams/eurocup, /en/video/eurocup). A page with one address for every competition — an article, a team's, player's, coach's or game's page — declares the competition it is in when its content arrives (PageCompetition, @v4/content-app/page-competition).

The category/ level

The category/ level (the site map review, 21 Oct 2026, for ELB to confirm) is the current site's: a category's slug sits after a fixed category segment, so it never shares an address with an article or a competition. /en/news/category/<category> is also the category's own page — where every article card's category chip goes — and a listing an editor sets up for that category (no competition) takes it over, its own header, featured article and blocks in place of the category's.

Every competition's news exists

The site map has each competition's news Built. With no listing set up at /en/news/eurocup or /en/news/eurocup/category/interviews, the address is the GENERATED listing (content-app lib/generated-listing.tsx): the competition's own articles and those set to Also Show In it — the default's also lists articles with no competition — headed by its name, with the season and team filters. An editor's listing wins wherever it names at least as much of the address, so the generated category listing still answers under a competition whose own listing an editor has set up.

One address per page

A data area's list takes only the filters its area declares (LIST_FILTERS, packages/basketball-app/src/lib/area.ts), and a team's, player's, coach's or game's page nothing after its slug but the pages declared under it (ENTITY_PAGES — a team's roster); anything else is not found. The areas are SPORT_AREAS (@v4/model core/sections.ts): teams, players, coaches, games, standings and stats. How the routes are built is in Data-area routes. The Game Centre is pre-match, live and post-match at ONE address.

A content page's competition is its top-level parent's: a child page set to another competition still sits under its parent. That keeps a section's pages together and means one field — on the top-level page — decides where a whole tree lives.

What each section has

What each section has is said in config, because nothing in the CMS does: club.config.ts → sections.capabilities, by slug — sport (data areas), video (a video home) and switcher (a row in the competition switcher). ELB's Corporate has none of the three. A section the config does not name has everything, so a new competition needs no config. pnpm compose writes it to src/lib/sections.generated.ts, and src/lib/section-capabilities.ts configures @v4/model sectionCapabilities with it at start-up.

Redirects to the one address

A page asked for at any other address redirects (308) to the one it has: its old unprefixed address, a child under the wrong section, or the default competition's own slug in front (/en/euroleague/about → /en/about). An old address with neither the locale nor the right section takes ONE redirect: the proxy drops the default competition's slug as it adds the locale (/euroleague/about → /en/about), from club.config.ts → sections.default, which must name the same competition as Site Settings (out of step costs one extra redirect, never a wrong page). The old /news/listing/<slug> and /…/filter/… addresses redirect the same way.

How it is built

Where a page's section comes from

Layer Holds
Contentful The competitions (club/0030, club/0035), Site Settings' default competition, each top-level page's and listing's competition, the competition chrome (club/0044)
club.config.ts → sections What the CMS cannot say: default (the default competition's slug) and capabilities per section — sport (data areas), video (a video home), switcher (a row in the competition switcher). Corporate has none of the three; a section not named has everything.
apps/web/src/lib/sections.generated.ts DEFAULT_SECTION_SLUG and SECTION_CAPABILITIES, written by pnpm compose from that block
@v4/model core/sections.ts The pure address rules: sectionPrefix, contentPageHref, newsListingAt, sectionFromPath, SPORT_AREAS, …
The sections snapshot @v4/content-app's siteSectionsLoader reads competitions, the default, page and listing competitions once per render pass, and hands them to the routes, chrome, 404 page and sitemaps

The modules

  • @v4/model core/sections.ts — the pure rules: sectionPrefix, contentPageHref, newsListingPaths, newsListingForSegments, generatedNewsListingForSegments, newsListingAt (which of the two an address names, the route's rule), sectionForSegments; and core/section-capabilities.ts, what each section has.
  • The sections snapshot — GetSiteSections (content-app, siteSectionsLoader) reads the competitions, the default, each top-level page's competition and each listing's filters ONCE per render pass, and the routes, the chrome, the 404 page and the sitemaps hand it to the transforms as sections. resolveLink then puts a content page under its competition and names a listing by what it lists. It is a snapshot rather than extra fields on every Link because the chrome query is already the most expensive on the site (18,804 on Contentful's cost counter).
  • /[locale]/[...path] (the content-page route) reads a first segment that is a non-default competition's slug as that section: alone it is the competition's homepage, followed by more it is a page in that section.
  • /[locale]/news/[...path] (news-path) replaces news/[article]: the leading segments are tried as a listing's address first, three, two then one, and a single segment no listing claims is an article. A listing wins, so an article whose slug a listing uses is unreachable at that address. Every link to one of these listings reads the same rule (newsListingAt) to name the current season (seasonedListingHref with the sections; the chrome carries them as sections.newsListings), so a tap never takes the season redirect that used to flash the footer.
  • Competition listings read GetArticleCardsInCompetition: an article's Competition, or its Also Show In.

What each area does per section

  • Content — [...path] reads a leading non-default competition slug as the section; news/[...path] tries the leading segments as a listing first and generates a competition's news listing when no editor listing claims the address.
  • Video — /en/video/<competition> is the competition's video home (see The video domain); Corporate has none.
  • Basketball — every area lists per competition at /en/<area>/<competition>, and a bare /en/<area> redirects to the default competition's list; Corporate has no data areas. The engine code for each slug comes from competitionEngine.competitions (see Basketball and the Competition Engine).

Checking for clashes

pnpm --filter @v4/content-app check-addresses -- --env=develop

Read-only. It names every page no address can reach: an article a one-segment listing shadows, a listing pushed to its fallback address because another took its own, a competition or category slug that is one of news's fixed segments (page, season, category, tag, listing, filter) or a site route, and a top-level content page whose slug is a competition's.

The chrome follows the section

One navigation, rewritten per section

Editors build ONE main navigation in Site Settings, and since club/0044 a competition may name its own navigation and footer (an empty field keeps Site Settings').

In a non-default competition's section — its pages, its homepage, its listings, its video home and its data areas (/en/teams/eurocup) — the Home, News and Video links go to that section's own (/en/eurocup, /en/news/eurocup, /en/video/eurocup), and a link to a bare data area or to the default competition's list goes to the section's list (/en/teams/eurocup) — the site map's "the menu's Home, News, Video, Teams and the rest point at EuroCup's addresses". In a section without a video home or data areas (Corporate) those links stay EuroLeague's; in the default competition's section, and on an article, a video or a team's, player's or game's page, they stay as authored. Nothing is hardcoded: a competition's own listing is the one set to it with no category, else its generated one.

The server computes the equivalents with the chrome (SiteChromeView.sections) and apps/web/src/lib/section-nav.ts applies them once the chrome knows the path.

The header's competition switcher leaves out a section configured without a row in it (switcher: false — Corporate, which the footer links instead). The layout renders every competition's footer and shows the one the page's competition names, so a EuroCup page never paints EuroLeague's first.

Pages that declare their competition

A page with one address for every competition — an article, a team's, player's, coach's or game's page — declares the competition it is in when its content arrives (PageCompetition), and the header, the crest strip, the colours and the footer follow it. Until then its loading state keeps the competition the visitor is already in (KeepPageCompetition, in the app's placeholder and the data areas' skeletons): without it, a EuroCup crest loaded the club's page under EuroLeague's chrome and then switched back. Only such pages keep it — the app marks each new address with where its competition comes from (competitionFrom, data-competition-from), so the default competition's home, listings, lists and content pages switch the chrome at the click, as before.

The first load

On a page's FIRST load (a refresh, a link from elsewhere) the browser knows the address before React does: the head script stamps the page's competition on <html> (data-chrome-competition) before the first paint, and the colours and the footer follow it from there. The header is the competition's too — its switcher logo, crest strip and menu — and the shell is prerendered once for every page, so the server renders one header per competition and CSS shows the page's from the first paint (lib/header-variants.ts), as it does the footer. Before, a refresh painted EuroLeague's header on every EuroCup and NextGen page until the page had hydrated. The page hydrates the header it shows and keeps it, following the page from then on; the others are removed without being hydrated. Until then the document holds several SiteHeaderGlobals, so code that measures the header takes the one on screen (shownSiteHeader, @v4/content-app/chrome-marks), never the first it finds.

The locale segment

club.config.ts sets locales: { supported: ["en-US"], default: "en-US", routing: "segment" }, so every page is routed under app/[locale], the segment being the locale's language (en-US → en, or the whole tag when two locales share a language). pnpm compose writes the route files there and the constants every part reads (apps/web/src/lib/locale-routing.generated.ts, LOCALE_SEGMENTS = { "en-US": "en" }). The pieces:

  • The proxy (apps/web/src/middleware.ts) sends an address without a supported locale's segment to the default's (308), dropping the default competition's slug in the same redirect (sections.default, src/lib/sections.generated.ts).
  • Links — every package builds plain site paths (/news/eurocup) and the segment is added where a path leaves for the browser, through @v4/model localePath (configured once by src/lib/locale-path.ts). It is idempotent, so a path that already has the segment passes through. Client components, which never see that configuration, take addresses the server built, or src/lib/locale-routing.ts.
  • Routes receive their params without the segment, so nothing that reads a path changed.

A second locale would read the request's own segment (next/root-params) in localePath instead of the configured one — that function is the seam. Locales in general are covered in Localisation.

Adding a competition

Most of a new competition is content, because the sections are read from Contentful. What each piece needs, collected from the sections above:

  1. Create the Competition entry in Contentful with its Slug — that slug becomes the section's address segment (/en/<slug>, /en/news/<slug>, /en/teams/<slug>).
  2. Give it a homepage and top-level pages: a Home Page whose Competition is the new one, and top-level content pages set to it (their children follow the parent). Its news listing exists without any work — the generated listing answers until an editor sets one up.
  3. Say what the section lacks, if anything, in club.config.ts → sections.capabilities (sport, video, switcher), then run pnpm compose. A section the config does not name has everything, so a full competition needs no config.
  4. Map its Competition Engine code in club.config.ts → competitionEngine.competitions if it has data areas and its CMS entry does not carry the code yet (see Basketball and the Competition Engine).
  5. Give it a theme, optionally: until it is named in COMPETITION_THEMES it wears EuroLeague's colours (see Competition themes and Theme the site).
  6. Check for clashes with pnpm --filter @v4/content-app check-addresses -- --env=<env> (see Checking for clashes).

Competition themes

EuroCup and NextGen pages wear their own brand. ELB's Token Studio file carries one theme per competition; EuroLeague's is the base and is @v4/tokens-euro-league's global layer itself, so only EuroCup and NextGen add anything.

Generated as a difference

@v4/tokens-euro-league's generate runs scripts/generate-competition-themes.mjs after the main build. src/competition-themes.ts then exposes competitionThemes, keyed by COMPETITION_THEME_NAMES (EuroCup, NextGen). Each theme's global is a partial tree: only the global-contract leaves whose value differs from the EuroLeague layer. A typography step naming a family with no shipped @font-face is held at EuroLeague's, so a theme can never select a face the site cannot render.

Emitted on :root.<class>

apps/web/src/theme/competition-themes.css.ts, imported by the locale layout, declares each theme with createScopedOverrides(":root.ph__competition-<slug>", …). Because ELB's global layer is declared once on :root and inherited, the competition layer only has to out-specify that one rule (0-2-0 over 0-1-0) and declares the difference and nothing more — not the ~6,000 properties a full scoped theme would re-declare.

Applied before the first paint

apps/web/src/lib/competition-theme.ts maps a section slug to its theme (COMPETITION_THEMES = { eurocup: "EuroCup", nextgen: "NextGen" }) and decides the class for a path (competitionThemeFor). Two pieces put it on <html>:

  • competitionThemeScript — an inline script in the root layout sets the class before the first paint, so a EuroCup page never flashes EuroLeague's colours.
  • CompetitionThemeSync (apps/web/src/components/competition-theme-sync.tsx) keeps it in step across client navigations, and adopts the competition a page declares when its address names none.

A competition added in the CMS wears EuroLeague's colours until it is named in COMPETITION_THEMES; a section with no theme (Corporate) does the same.

How the layer fits among the override levels is on Colour sets and overrides; the token side is on @v4/tokens-euro-league.