Competition sections
How one site holds EuroLeague, EuroCup, NextGen and Corporate: the addresses, the chrome that follows them and the themes.
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/modelcore/sections.ts— the pure rules:sectionPrefix,contentPageHref,newsListingPaths,newsListingForSegments,generatedNewsListingForSegments,newsListingAt(which of the two an address names, the route's rule),sectionForSegments; andcore/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 assections.resolveLinkthen 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) replacesnews/[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 (seasonedListingHrefwith the sections; the chrome carries them assections.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 fromcompetitionEngine.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 competition switcher and the footer
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/modellocalePath(configured once bysrc/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, orsrc/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:
- 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>). - 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.
- Say what the section lacks, if anything, in
club.config.ts→sections.capabilities(sport,video,switcher), then runpnpm compose. A section the config does not name has everything, so a full competition needs no config. - Map its Competition Engine code in
club.config.ts→competitionEngine.competitionsif it has data areas and its CMS entry does not carry the code yet (see Basketball and the Competition Engine). - Give it a theme, optionally: until it is named in
COMPETITION_THEMESit wears EuroLeague's colours (see Competition themes and Theme the site). - 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.