Web app
Reference for apps/web: what it owns, its middleware and API routes, link resolution, caching, hosting and sign-in.
Overview
apps/web is deliberately THIN. It owns almost no page bodies — it composes
per-domain route packages and provides the shell (chrome, theming, the model
client, the API routes and the middleware). This page is the app's reference detail; how the
whole site is built lives in How this site is built, and how the
app renders, in
App & rendering.
What lives where
Route bodies live in domain packages, one per bought domain (
club.config.tsdomains: ["content", "video", "basketball"]):@v4/content-app— homepage, news listings (with their path-addressed filters), articles and content pages;@v4/video-app— the video landing, detail, collections, playlists and categories;@v4/basketball-app— the competition data areas:teams,players,coaches,games,standingsandstats, each a bare area address (redirecting to the default competition's list) plus a<area>/[...path]catch-all for a competition's list or one team, player, coach or game (twelve routes,packages/basketball-app/routes.json). These read the Competition Engine through@v4/competition-engine, configured once insrc/lib/competition-engine.ts.
Each exports per-route FACTORIES (
createNewsArticleRoute(deps)).apps/webis shims + chrome.pnpm composereads each package'sroutes.jsonand generates one shim per route undersrc/app/[locale]/**/page.tsxthat wires the factory to the app'sclient/locale/media/competitionEngine— the drift gate fails CI if a shim is stale. Hand-written pages (no GENERATED header) are never touched; there are two:src/app/[locale]/code-of-conduct/page.tsx(@v4/code-of-conduct) and the QA error pagesrc/app/[locale]/qa-922b04feaf/error-500/page.tsx(never indexed). The other 34 of the 36page.tsxfiles are generated.Data flows through
@v4/model: curated fragments → the typed client (src/lib/content.ts) → transforms → serialisable view models handed to ghost components. The app never writes raw Contentful selections. The basketball areas merge Competition Engine data with the editorial Contentful entries (@v4/competition-engine/merge, editorial wins when set).Chrome (nav, footer, site settings) is two cached queries in
src/lib/chrome.ts: the site chrome (taggedcontent:siteSettings,content:navigation) and each competition's chrome, which also carriescontent:competition. Both are rendered by@v4/ghost-corecomponents.
Locales and site sections
- Locale routing is by path segment (
locales.routing: "segment"inclub.config.ts), so every page lives undersrc/app/[locale]/. The middleware redirects (308) an address without a supported locale segment to the same page in the default locale. - Site sections — the competitions (EuroLeague, EuroCup, NextGen) and the
Corporate section — are declared in
club.config.tssectionsand resolved by@v4/modelcore/sections.ts. They decide a content page's address prefix, a news listing's named address and the data areas' competition segment. See Competition sections.
The middleware (src/middleware.ts)
Runs on page addresses (never on _next, static files, robots or sitemaps)
and on the basketball API prefixes. In order it:
- answers the basketball API routes (
/api/players|stats|games|live/…) with a 404 whenBASKETBALL_ROUTES=false, and lets them through otherwise; - 308-redirects an address with no locale segment;
- rewrites a data-area page to the not-found page, with a 404, when the basketball routes are switched off;
- marks the QA error page
X-Robots-Tagnoindex; - sets a Cloudflare
Cache-Tagheader per page fromsrc/lib/cache-tags.ts(see Caching).
API routes (src/app/api/)
| Route | Purpose |
|---|---|
/api/revalidate |
Contentful publish webhook and Competition Engine post-ingest hook → revalidateTag, then the Cloudflare purge |
/api/preview, /api/disable-preview |
Enter / leave Next draft mode for Contentful previews |
/api/auth/* |
Cortex single sign-on (five routes; see Single sign-on) |
/api/code-of-conduct/{games,reporter,report} |
The In-Arena Code of Conduct report (@v4/code-of-conduct/server) |
/api/games/{live,round}, /api/live/games |
The live Game Centre and the schedule's round switch |
/api/players/{hub,search}, /api/stats/teams |
Player hub / search and team stats for the client-side data surfaces |
/api/geolocation |
The reader's country, from request headers |
/api/download/[assetId] |
Serves a Download Block row's file so the browser saves it (that asset only, looked up in this space) |
/api/health |
Load-balancer health probe |
The players, stats, games and live routes belong to the basketball
domain and follow BASKETBALL_ROUTES.
The app's own responsibilities
- Composition —
club.config.ts(identity, domains, sections, env markers, feature integrations, hosting) is consumed by the compose generator and bynext.config.ts.next.config.tsreads it only for the image hosts and the client's name (the cache key prefix); the per-clientuse cachehandler is wired whenever the app is NOT on Vercel (process.env.VERCEL), never on thehostingblock. - Theming — the club theme is emitted in PARTS (
src/theme/):tokens.css.ts(every page: the global layer, declared once on:root, plus the ghost-core and ghost-content component themes),tokens-video.css.tsandtokens-basketball.css.ts(imported by the/videoand data-area segment layouts),tokens-home-hero.css.ts(the homepage hero widgets' basketball themes),competition-themes.css.ts(the EuroCup / NextGen layers on:root.<class>) andfonts.css.ts(the brand faces).next.config.tssetsPH_THEME_EMISSION=club, so the club theme is the one emitter and the package bootstraps only register. A route that renders a package must load that package's part, or its components render unstyled with nothing failing. - Feature integrations — wired by environment variables through
club.config.ts, each off unless its key is set: WSC Stories / Moments (@v4/wsc), the ELTV livestream (@v4/eltv), ECAL "Add to calendar" (@v4/ecal), measurement and consent — Google Tag Manager plus OneTrust (@v4/analytics) — and Cortex sign-on (@v4/cortex-sso). See Environment variables. - Draft preview — with
CONTENTFUL_PREVIEW_TOKENset, a request in Next draft mode is served live from the Contentful preview client, bypassing the cache (src/lib/content.ts);/api/previewchecksCONTENTFUL_PREVIEW_SECRETand that the draft exists before enabling draft mode. The preview URLs in Contentful are written bypackages/model/scripts/sync-preview-urls.mjs. - Rendering & caching — most surfaces are on-demand static (see Caching); pagination and listing filters are path addresses, to keep listings static too.
- SEO surfaces —
sitemap.xmlwith chunked children (src/app/sitemaps/sitemap.ts) androbots.ts. The sitemap is built from Contentful queries (articles, content pages, news listings, categories) and Competition Engine queries for the data areas (src/lib/sitemaps.ts); the basketball entries drop out whenBASKETBALL_ROUTES=false.robotsallows crawling only whenNEXT_PUBLIC_ROBOTS_ALLOW=true.global-error.tsxis the root error boundary.
Reading order
- This overview → 2. Link resolution
(how content links resolve) → 3.
Caching (rendering modes, tags, revalidation)
→ 4. Hosting (Flightcontrol, Redis, the
build) → 5. Single sign-on. Deeper:
how the site's domains are composed
(Domains and bolt-ons) and the
@v4/modelpackage (Model package).
Link resolution
Status: ratified 10 September 2026, and kept current since. The resolver
(packages/model/src/core/transforms/link.ts, resolveLink) implements this page; a change to
either changes both in the same commit. Where a rule was a deliberate choice rather than the
obvious behaviour, the reason is given beside it, so nobody "fixes" it back without a decision.
The Link content type
One link content type (Standard Link) serves the site (baseline:
packages/model/migrations/core/0001-baseline-core.cjs:7-25). Its fields and the model's own
editor help text are the contract the resolver implements. The model narrows internalLink
to the three content targets this site has, and subPage and deepLink have been disabled
(migrations/club/0048-capture-editor-changes.cjs; they leave the model in the
3 November 2026 clean-up). The resolver reads neither.
| Field | Type | Model contract (help text) |
|---|---|---|
title |
Symbol, required, localised | "the link copy shown on the front-end" |
internalLink |
Entry ref | Validated to: articlePage, contentPage, newsListingPage |
externalLink |
Symbol, localised | "On web, Internal link will be used instead if set. On App, Deep link will be used instead if set." |
openInNewTab |
Boolean | "If not selected, defaults to No for Internal pages and Yes for External links." (see target/rel: only content blocks and the home hero honour its "Yes for External") |
subPage |
Symbol, in: ["fixtures","results","table"] |
Disabled (club/0048). A sub-route discriminator for fixture listing pages, which this site does not have; not read |
deepLink |
Symbol ("Deep Link (APP only)") | Disabled (club/0048). For a mobile app only — never a web href; not read |
Field priority
| # | Rule | Why |
|---|---|---|
| 1 | An internal link wins over an external URL when both are set | The model's externalLink help text says so, and an editor reads it |
| 2 | externalLink is used when there is no (resolvable) internal link |
— |
| 3 | deepLink is never a web href |
It is an app-only field |
| 4 | subPage is never a path of its own. Disabled in this space and not read by the resolver (InternalLinkContext is now empty) |
It only ever chose between fixture listing sub-routes |
| 5 | An internal link that resolves to no route falls back to externalLink, then "#" |
Sending an unresolvable link to the home page would hide broken content; "#" keeps the item visible and flaggable in QA, and trying externalLink first is more useful |
Content-type → route mapping
__typename |
Address |
|---|---|
ArticlePage |
/news/{slug}, served by content-app's news/[...path] catch-all. With no slug → the /news landing, the safer degrade than an address containing undefined |
ContentPage |
/{fullPath}, with a slug fallback when fullPath is absent (resolveLink, link.ts). With the site's sections passed (the normal path) the page sits under its top-level parent's competition — /eurocup/about (contentPageHref, @v4/model core/sections.ts) |
NewsListingPage |
/news for the default listing (the one Site Settings' defaultNewsLandingPage names). A secondary, with the site's sections passed (the normal path), resolves to its NAMED address — /news/<competition>, /news/category/<cat>, /news/<competition>/category/<cat> or /news/<slug> (sections.newsListingPaths); /news/listing/{slug} only when no sections are passed (the route still exists) |
AppContentPage |
# — the fragment selects __typename only, so it is never a routable web link |
HomePage* |
/ |
VideoListingPage* |
/video |
| anything else | external, then # (priority rule 5) |
* Not in the internalLink validation list; kept in the resolver's INTERNAL_ROUTES as
harmless forward-compatibility.
The basketball pages. The data areas exist at /teams/<slug>, /players/<slug>,
/coaches/<slug> and /games/<slug> (plus each area's competition lists — SPORT_AREAS,
packages/model/src/core/sections.ts; packages/basketball-app/routes.json), but a Standard
Link cannot target a Team, Player, Coach or Game entry: they are not in the internalLink
validation, and no bolt-on InternalLinkResolver is wired in the app (the type and a
pass-through exist in @v4/model core/transforms/site-chrome.ts and error-page.ts, with no
resolvers supplied). An editor links to one by typing its path as the External Link
(/teams/real-madrid); a site path gets the locale segment like any other (localePath).
The fragment (packages/model/src/core/fragments/link.graphql) selects routing keys for
the content targets only: ContentPage.fullPath and slug (plus its sys.id, so a page
that links here is refreshed when the target page moves), ArticlePage.slug
and NewsListingPage.slug with its linkedFrom.siteSettingsCollection default check.
The site's sections are part of the input
resolveLink, entryHref and canonicalHref take an optional sections
(GetSiteSections, @v4/model core/sections.ts). With them, a content page is prefixed by
its competition and a secondary news listing is named by what it lists; without them every
address is the default section's. Every chrome and page caller passes them; the article body's
rich-text links (below) are resolved WITHOUT them. The sections, and the ELB Corporate section
(sections.capabilities.corporate in apps/web/club.config.ts), are described in the
Competition sections page, which is normative for addresses.
Canonical links
A page's Canonical Link (a Standard Link) goes through canonicalHref (link.ts): the same
rules — an internal page beats an external address — and undefined, so the page falls back to
its own address, when the field is empty, resolves to "#", or its External Link is blank or
whitespace. The link's Title is irrelevant there.
Off-site addresses
isOffSiteAddress (absolute http(s):// or protocol-relative //, case-insensitive, after
leading whitespace) is the one test for "on another site". It decides the unset new-tab case
below, and it also draws the external-arrow icon on navigation items in the header and mega
menu (apps/web/src/components/site-chrome.tsx).
A link to a listing names the season
A listing's address always carries its season (ruling of 9 Oct 2026). A bare listing address
— the /news and /news/listing/{slug} the table above gives a NewsListingPage, or a
typed /news/category/{slug} or /news/tag/{id} — is redirected while the listing streams,
which empties the page under the header and shows the footer before the listing arrives;
from the header or footer, a full page load, it also loads a second document.
The resolver cannot name the season — the model does not know it — so the app does, where it
renders the link: seasonedListingHref(href, currentSeason) (@v4/content-app,
lib/listing-filters.ts) rewrites a link to a listing as the address the listing renders at,
with the current season, and keeps every other address as resolved. It runs on the site
chrome (chromeSeasonLoader: Site Settings' Current Season, the listings' fall-back only
while that is empty), header blocks, the news listing's sub-navigation, block calls to action
and Show All, the home hero's buttons, an article's related-content button, the basketball
team, player and coach pages (packages/basketball-app/src/{teams/team-page,players/player-page,coaches/coach-page}.tsx)
and the links in rich text (the href hook on the rich-text renderers). The header's current-section mark
reads a link the chrome wrote that way as the listing it leads to (currentSection). A
competition's news (/news/eurocup, /news/eurocup/category/{slug}) and a listing named by
its own slug have an article's kind of address, so each caller also hands it the site's
sections, and the link is read with the news/[...path] route's own rule (newsListingAt):
the carousel's "EuroCup news" and the News a competition's section swaps into the header
flashed the footer until they did. Two
links keep the hop: one inside a Script Embed's own markup, which the site renders as
written, and a typed /news/listing/{slug} naming the DEFAULT listing, whose route sends
every form of that address on to /news (a Standard Link to that listing resolves to /news
already, by the table above).
Links inside rich text
A rich-text entry-hyperlink — body copy linking to another entry — takes the same table:
entryHref (link.ts) is the internal branch above on its own, fed the target's routing
keys from RichTextEntryLinkFields
(packages/model/src/content/fragments/rich-text-entry-link.graphql), the same
baseline-only selection as LinkFields. A page fetches them in one follow-up query (see the
rich-text rendering guide). The node has no externalLink to fall back on, so a target with
no route here — or one the query does not return because it is unpublished — links to "#"
(on the article body, which resolves without a follow-up query, it keeps its words unlinked),
as priority rule 5 says. A rich-text hyperlink keeps the document's uri and opens in the
same tab: the node carries no new-tab flag. The one exception is a Text Block's Body Copy:
there a link whose address is on another site (isOffSiteAddress, the test the content blocks
apply to an unset box) opens in a new tab with rel="noopener noreferrer" (user ruling after
QA, 22 Oct 2026). Either kind of link to a LISTING then names the current season, like every link the
site writes (see "A link to a listing names the season" above).
Scope, today: two surfaces resolve entry links.
- The article body: the article query selects each hyperlink target's
routing keys inline, and
normaliseRichTextbuilds anentryHrefsmap from them (packages/model/src/content/transforms/rich-text.ts), which the renderer uses (packages/content-app/src/lib/rich-text.tsx). No follow-up query. These links are resolved WITHOUT the site's sections, so a content page gets no competition prefix and a secondary listing its/news/listing/{slug}address. - The Text and Image block's Body Copy, on the Content and Home pages, through the
follow-up query
GetRichTextEntryLinks(content-apphome/index.tsx,content-page/index.tsx,prepare-blocks.tsx).
Every other rich-text surface — accordion sections, the table and text blocks — still renders an entry link's words as plain text, even though their fields admit the node (see Rich-text rendering). A ticket for one of those surfaces must not promise the link until it is wired.
External URLs — target and rel
| Rule | Detail |
|---|---|
Default — the site chrome (header, footer, sponsor tiles, the 404 page's links) and the page-level links (a content page's header button and sub-navigation, the news listing's sub-navigation, an article's related-content button): openInNewTab === true → target="_blank"; anything else (including unset) → _self |
Kept by user ruling of 14 Oct 2026, although the help text says unset means "Yes for External links": the seeded header and footer links point at euroleaguebasketball.net with the box unset. ResolvedLink.openInNewTab carries the boolean, and the site-chrome surfaces listed under decision 2 below apply it (target="_blank" + rel="noopener noreferrer") through the ghost-core link components via their nativeProps pass-through. |
Content blocks and the home hero's buttons: an explicit Yes → _blank and No → _self; unset → _blank for an external address (no internal page resolved, and the External Link is an absolute http(s):// or protocol-relative // URL), _self for an internal page, a site path, #, mailto: or tel: |
Follows the field's help text (user ruling after QA, 14 Oct 2026) — every link content-blocks.ts resolves (the news, promo, gateway and tabbed carousels, the promo and text-with-image blocks, on whichever page places them) and the home hero's buttons (home-page.ts) pass resolveLink(link, { externalOpensNewTab: true }). The buttons open the new tab with window.open(href, "_blank", "noopener") (linkCta; the text-with-image block's own handler in blocks-view.tsx). See decision 7. |
rel="noopener noreferrer" wherever target="_blank" is set |
Every site-chrome surface that opens a new tab (decision 2) sets both; a link that opens in the same tab carries no rel. |
In-page anchors
The Link type has no anchor field. A hash inside externalLink (e.g.
https://site/page#section, or /page#section) passes through verbatim.
Fallbacks — unresolvable / unpublished targets
| Scenario | Result |
|---|---|
| Link entry missing/null | resolveLink returns undefined; nav items are dropped |
No title |
Dropped: a nav row with no visible label is content rot, and an unlabelled anchor is worse |
| Internal target unpublished/unresolvable, no external URL | Kept with href="#": a silently missing nav item is invisible rot, while "#" is visible in QA and greppable. A consumer that wants the item gone filters href === "#" |
| Unpublished nested refs | n/a — no Link target here has nested routing refs; if one ever does, guard them in the fragment with where: { sys: { publishedVersion_exists: true } } |
Locale prefixing
With locales.routing: "segment" in apps/web/club.config.ts (15 Oct 2026) every page lives
under app/[locale] and every resolved href carries the locale's segment (/en/news):
resolveLink and entryHref pass their result through @v4/model localePath — the single
point — and the other address builders do the same where a path leaves for the browser (see
the Competition sections guide). Route patterns are NOT localised (/en/news, never
/fr/infos); that remains a decision for when a second locale lands.
Special cases
mailto:/tel:— the Link type has no mailto/tel affordance. Amailto:/tel:URI typed intoexternalLinkpasses through verbatim, and stays in the same tab even where an unset box would open an off-site address in a new one.- Query strings — the Link type has no query field. A query string inside
externalLinkpasses through verbatim. deepLink— see priority rule 3: app-only, never a web href.- Primary-nav cap — how many items the primary nav shows is a site-chrome rule, not a link-resolution rule.
- The content page header's Back button — not a Standard Link, so the priority rules
above do not apply: it goes to the Parent Page's
fullPath(a leading slash added if missing, exactly as the page's own path), with no slug fallback (unlike decision 3), and there is no button at all when the parent has nofullPathor is hidden for this locale — such a parent is not served, so any address would be the not-found page. ghost-core'sButtonBackis a<button>, so it is followed withrouter.push(throughlinkCta) and cannot be opened in a new tab (contentPageFromFragment→header.parentPath).
Decisions on record
Each of these needed routes, model work or component API beyond the resolver, so it is recorded here with its outcome:
- Non-default news listings — RESOLVED.
LinkFieldsselectsNewsListingPage.linkedFrom.siteSettingsCollection(id-only,limit: 1), and the resolver sends the DEFAULT listing (non-empty collection = linked fromsiteSettings.defaultNewsLandingPage) to/newsand every other listing to its named address (the mapping table above), or/news/listing/{slug}without the sections.@v4/content-appships thenews-listing-by-slugroute (base +/page/[page]) — it renders the listing entity like/newsdoes, filtered to the listing's category/tags. A secondary listing with no slug degrades to/news. openInNewTabon the ghost-core nav/footer link components — RESOLVED. The resolver carries the boolean (ResolvedLink.openInNewTab), andsite-chromemaps it to{ target: "_blank", rel: "noopener noreferrer" }on these surfaces: the promo anchor (its own<a>), primary nav, the flyout/mega renderings, the More navigation (the secondary nav row and the mega menu's external tiles), the footer hero buttons, the footer useful/legal link groups and the footer sponsor tiles (SponsorItemView.openInNewTab, read from the Image Link's own link). The footer groups and tiles were only wired in the footer redesign's QA (8 Oct 2026); until then the footer passed each link's label and href alone. Not wired in the ELB chrome: the brand flyout's other-sites rows and the header crest strip, whose item types (BrandNavigationFlyoutSite,TeamNavSelectionTeam) carry nonativeProps(wiring them is a component API change), and the hygiene links, which the ELB chrome does not render. The ghost-core leaves already exposed the native<a>surface via anativePropspass-through, so the only additions werenativePropson the intermediate item-shape types that omitted it (HygieneNavListItem,NavigationListMoreMenuItem,GlobalNavLinkListItem,GlobalNavSite,LinkListItemData) plus a forwarding line in the two composites that copy fields explicitly (GlobalNav,LinkList). No new leaf prop was invented.- ContentPage slug fallback — RESOLVED.
LinkFieldsnow selectsContentPage.slug, andinternalHreffalls back to/{slug}whenfullPathis absent (still"#"when both are absent). It papers over a content defect (fullPathis required by the publishing flow), which is why the link stays visible-but-shallow rather than dead: a bare slug is a better degrade than a broken"#", and a nested page with a missingfullPathis content worth flagging in QA either way. - Basketball pages are not Link targets — the team, player, coach and game pages exist
(see "The basketball pages" above), but the Link type's
internalLinkcannot point at their entries and no resolver is wired, so editors type the path as the External Link. Making them linkable needs a model change (widening the validation, which changes the GraphQL union), a*LinkFieldsfragment selecting their slugs and anInternalLinkResolverpassed by every caller. - Localised route patterns — the locale PREFIX is applied (
localePath, the locale section above). Whether route patterns are localised too (/fr/infos/…) is a decision for when a second locale lands. - Unknown-target fallback
"#", not the home page — a deliberate choice (priority rule 5), recorded so nobody "fixes" it back to home without a decision. - "Open in new tab" left unset — RESOLVED for content blocks and the home hero,
14 October 2026 (found in QA: a promo carousel card and a tabbed
carousel's "View All" that point at other sites opened in the same tab). Content blocks
and the home hero's buttons follow the field's help text: an explicit Yes or No always
wins; left unset, an external address — the External Link, used because no internal page
resolved, when it is an absolute
http(s)://or protocol-relative//URL — opens in a new tab, while an internal page, a site path typed as the External Link (/news),#,mailto:andtel:stay in the same tab. The header, footer, sponsor tiles, the 404 page's links, a content page's header button, an article's related-content button and the news listing's sub-navigation keep unset = the same tab by user ruling: the seeded header and footer links point at euroleaguebasketball.net with the box unset, and the help text would flip over a hundred of them. The opt-in isresolveLink'sexternalOpensNewTab, passed only bycontent-blocks.tsandhome-page.ts. Recorded so nobody widens or drops it without a decision.
Caching & revalidation
The system is DECLARATIVE: queries register what they consumed (tags) as
they run, and invalidation follows the data. No list of URLs exists
anywhere. Three sources feed it: Contentful (the content:* tags), the
Competition Engine (the ce:* tags) and the video platform (media-*, time-based
only — see below). Two cache layers sit in front of a reader: Next's own cache at
the origin, and the Cloudflare edge.
The Contentful matrix (CONTENT_CACHE in packages/content-app/src/lib/cache.ts)
| Surface | Tags | Profile (stale/reval/expire s) |
|---|---|---|
| Article detail | content:articlePage:{slug} ONLY (see rules) |
editorialDetail 60/300/3600 |
| Article cards (listings, rails, home) | content:articlePage |
editorialListing 30/120/1800 |
| Home page | content:homePage, content:articlePage |
editorialListing 30/120/1800 |
| News listing config | content:newsListingPage, content:articlePage |
editorialListing 30/120/1800 |
| Categories | content:category |
slowVocab 300/3600/86400 |
| Content page detail | content:contentPage:{path} only |
editorialDetail 60/300/3600 |
| Rich-text entry links (follow-up) | content:entry:{id} for EVERY requested target |
editorialDetail 60/300/3600 |
| Current season (listings) / chrome season | content:siteSettings |
editorialListing / chrome |
| Recent article slugs (build) / next article | content:articlePage |
editorialListing 30/120/1800 |
| Site sections | content:competition, content:siteSettings, content:contentPage, content:newsListingPage |
chrome 60/300/86400 |
| Teams (listing filter choices) | content:team |
slowVocab 300/3600/86400 |
| Players (editorial twins) | content:player |
slowVocab 300/3600/86400 |
| Season sponsors | content:season, content:seasonSponsor |
chrome 60/300/86400 |
| Site chrome | content:siteSettings, content:navigation (each competition's chrome adds content:competition) |
chrome 60/300/86400 |
| Sitemaps | per-surface content tags | slowVocab 300/3600/86400 |
Automatic reference tags. On top of the descriptor's tags, the app's use cache
boundary (apps/web/src/lib/content.ts) tags every cached query with
content:entry:{id} for each sys.id its result embeds, bounded by
boundedEntryIds (src/lib/entry-ids.ts). The chrome is always over that budget
(~320 entries) and is exempt from the warning, because every chrome type refreshes it
through the chrome tags instead.
Not every query goes through CONTENT_CACHE yet. The chrome
(apps/web/src/lib/chrome.ts) and the sitemaps (src/lib/sitemaps.ts) pass inline tag
literals with a CACHE_PROFILE; video-app (video-landing/index.tsx,
lib/associated-videos.ts, lib/video-title.ts, lib/category-slugs.ts,
lib/video-competitions.ts) and basketball-app (article-widgets/index.tsx,
lib/clock.ts) use inline profiles. The rule below stands; these are the
exceptions to fold back in.
Each surface names a CACHE_PROFILE — a cacheLife {stale, revalidate, expire}
timespan. stale is the serve-stale-while-revalidating window (a mass eviction
serves stale + rebuilds in the background for TIME-based expiry); revalidate/expire
are the self-healing floor if a webhook is ever lost. The webhook is the freshness
mechanism, and it EXPIRES tags immediately (a hard miss), not serve-stale.
The webhook
POST /api/revalidate, gated by the x-revalidate-secret header
(CONTENTFUL_WEBHOOK_SECRET; see Environment variables).
An unset secret answers 503, a wrong one 403, and a payload that yields no tags 400. The
payload's content type
maps to tags via apps/web/src/lib/revalidation.ts (tagsForPublish,
pure and unit-tested): the coarse content:{type} tag always, slug-level
tags for articles and content pages (every locale's slug value), address tags for content pages
(every locale's Full Path, leading-slash: the key the page is cached under), the two
chrome tags together for every chrome type (siteSettings, navigation,
navigationItem, and the footer's footerBlock, sponsorGroup, imageLink,
linkList, contactDetails) — a nav or footer edit refreshes every page — and the per-entry content:entry:{id} reference tag, so
publishing any entry refreshes every page that embeds it (omitted for the
SHARED_TYPES set — exactly siteSettings, navigation, navigationItem and
category — to bound fan-out; the footer's chrome types still emit their entry tags). A link counts when its
target's sys.id is selected: LinkFields selects it for a content page, so
the pages linking to one follow it to a new Full Path.
After expiring the tags the route purges the same tags at the Cloudflare edge
(below) and answers { "revalidated": [...tags] }.
The Competition Engine
The data areas read the engine through @v4/competition-engine. Each read goes through
cachedEngineGet (packages/competition-engine/src/cached-fetch.ts), a use cache
boundary keyed by the URL:
- Tags — every read carries the coarse
cetag plus fine ones:ce:competition:{code},ce:season:{code},ce:team:{clubCode},ce:player:{personCode},ce:coach:{personCode},ce:game:{season}:{gameCode}(CE_TAG,src/tags.ts). - Lives (
CE_LIFE, stale/revalidate/expire s) —structural(seasons, team lists, rosters, profiles, coaches, search) andcomputed(standings, leaders, stats, records) 60/300/3600;games(the schedule) andgameDetail30/60/3600;played(a finished game) 60/300/3600. - Failures are cached too, briefly:
FAILED_LIFE0/30/300, so a down engine is asked again after half a minute. Each request times out afterENGINE_TIMEOUT_MS(10 s). - Live endpoints (the live Game Centre, the live scoreboard) use
noStoreand are never server-cached.
Post-ingest revalidation. The engine is not Contentful, so its ingester tells the site
what it touched: after each run it calls the same /api/revalidate route, with the same
secret, and a body of { "source": "competition-engine", "seasons", "teams", "players", "coaches", "games": [{ "season", "code" }], "tags" } (every field optional).
tagsForEngineRun (apps/web/src/lib/revalidation.ts) turns it into ce:* tags, keeps only
well-formed ready-made tags, and ALWAYS includes the coarse ce — the edge carries only that
one for the data areas, so a run clears every data page there while the fine tags expire the
origin's fetches precisely.
The Cloudflare edge
src/middleware.ts sets a Cache-Tag response header on every page, derived from its path
(apps/web/src/lib/cache-tags.ts): the chrome tags everywhere; the listing, article and
content-page surface tags (with the article's slug tag); for the data areas ce,
content:team and content:player, plus content:season and content:seasonSponsor on
games, players and stats; and chrome only for /video. The header is inert while
Cloudflare passes HTML through, and is read once Cloudflare caches HTML (Enterprise).
After revalidateTag, the webhook calls purgeEdgeByTags (src/lib/cloudflare-purge.ts).
It is a no-op unless CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID are set, and never fails
the webhook. Per-entry reference tags are not in the edge header, so a publish of an embedded
entry (a block, an author…) coarse-purges every edge-cached content surface instead (the
"coarse-on-reference" rule).
Video
The video pages read the Stadion video platform through @v4/media, whose fetchers carry
next: { tags, revalidate: 300 } with the MEDIA_TAGS names (media-video,
media-landing, media-collection, media-playlist, media-rail;
packages/media/src/server.ts). Nothing emits them yet — the revalidate route knows only
content:* and ce:* — so platform video expires by time alone until a platform webhook
arrives. The Contentful reads video-app makes (landing settings, categories) carry their own
inline profiles (above).
Draft preview
With CONTENTFUL_PREVIEW_TOKEN set, a request in Next draft mode is served live from the
Contentful preview client, which is deliberately NOT wrapped in use cache
(apps/web/src/lib/content.ts); every other request goes through the cached delivery path.
Without a preview token draftMode() is never consulted, so nothing becomes request-time.
The basketball routes read through the delivery-only client, so their pages stay cacheable.
Rules
- A new query names its surface in the matrix — never an inline tag literal. One reviewed place, or the tag vocabulary drifts.
- Detail queries wear ONLY their slug tag — never the coarse tag, or one publish expires every article's cached page. The accepted trade: a detail page's related-content rail catches up within its TTL when one of the RELATED articles changes, instead of instantly. Listings keep the coarse tag and refresh immediately.
- Slug tags must match what the detail query registered — the query tags by the request key (slug/path); the webhook emits the same values from the payload. A mismatch is NOT harmless: a detail query wears no coarse tag, and a not-found result holds no entry to carry a reference tag, so nothing expires it. Content pages had exactly this until the fix (the webhook emitted the slug, the query registered the Full Path): a page just moved under a Parent Page kept showing the not-found page at its new address until its cache aged out.
- Engine reads go through
cachedEngineGetwith aCE_LIFEclass andCE_TAGtags; a live endpoint usesnoStore. Never a barefetchto the engine. - The app owns the cache boundary. Under
cacheComponents: truethe model client is wrapped in ause cachefunction (apps/web/src/lib/content.ts) that applies the descriptor's tags viacacheTag()and its profile viacacheLife(). The framework-free client sets no fetch cache hints, and the delivery TOKEN lives only in that module client — never a cache-key argument.
Multi-instance: the shared-tags cache handler
The app runs cacheComponents, so use cache resolves handlers ONLY through the
new cacheHandlers map — the legacy singular cacheHandler is never consulted.
apps/web/use-cache-handler.mjs is registered as cacheHandlers.default. It
delegates entry STORAGE to Next's built-in per-instance handler and SHARES the tag
invalidation manifest across replicas via a fixed Redis key (usecache:tagman):
updateTags mirrors each revalidation into the hash, refreshTags (run before
every request) pulls it back into the local tags manifest. So one webhook
revalidateTag reaches every replica within a request, not just the one it hit.
- Wired on a BUILD-STABLE gate: self-hosted (non-Vercel), not the Redis URL.
On Flightcontrol
CACHE_REDIS_URLis a runtimefromServicebinding ABSENT at build; gating the config on it skipped the handler entirely (leaving revalidation per-instance). So next.config wires it whenever the build is not on Vercel, and the handler reads the Redis URL at RUNTIME. - Isolation is by Redis instance, by ruling. Each environment's app points at its
OWN Redis; never one shared with another app. The manifest uses a fixed key — the
separate Redis is the isolation boundary (a prefix could not be
sourced consistently across Next's bundled vs raw-module realms).
next.config.tsstill passesenv: { CACHE_PREFIX }as defence-in-depth, and the handler deliberately ignores it; that is intended, not a bug to fix. - Degrades to LOCAL when there is no Redis (a single self-hosted instance, or a build): the built-in per-instance cache. A Vercel deployment is never wired (Vercel shares the cache natively).
- Fails safe. Any Redis error degrades to local per-instance behaviour, never a request failure; a reconnect cooldown avoids hammering Redis during an outage.
The webhook must EXPIRE tags, not mark them stale: it calls
revalidateTag(tag, { expire: 0 }) (immediate expiration). "max" marks a tag
stale but hard-expires it ~1 year out (SWR), so the FE would only refresh on the
surface's own ~120s window.
Rendering & pagination
Article detail, content pages and the home page are ON-DEMAND STATIC — no
searchParams, cached fetches, so Next's full-route cache serves the
rendered HTML and tags invalidate it.
Listings are now static too: pagination is PATH-BASED.
- Page number →
/news/page/2— a static, individually-cacheable route; page 1 is the bare/news, and/news/page/1redirects there. - One category / tag →
/news/category/x(+/…/page/2). - Filters are path addresses too: key/value pairs in one fixed order after
the listing's own address —
news/season/[...season],news/category/[category]/[...address],news/tag/[tag]/[...address],news/listing/[newsListing]/[...address]— with an optional trailingpage/<n>. The old/filter/addresses only redirect (content-applib/listing-filters.ts). See Pagination and filtering.
Every news route shares one generated Suspense boundary, news/layout.tsx, so a
navigation within News keeps the current page on screen until the next is ready,
instead of the loading box.
Unlisted addresses upgrade in the background. partialPrefetching: true in
next.config.ts: an address not prerendered at build (every team, player and game
page; an article published since the deploy) is served its App Shell on the first
visit and upgraded to a cached page behind it, so the next visit lands on the finished
page. It upgrades only what reads through use cache (the engine reads and the
delivery client).
Static files. next.config.ts headers() sends public/ files a cache lifetime:
/fonts/* a week fresh, /brand/*, /images/* and /crest.svg a day, each with a
longer stale-while-revalidate.
A new listing surface MUST NOT read searchParams for anything bounded —
that silently makes the high-traffic page-1 case dynamic. The same factory
serves /news and /news/page/[page] (it reads the optional page path
segment); routes.json carries the -paged variants and pnpm compose
generates the shims.
Hosting: Flightcontrol (AWS)
flightcontrol.json at the repo root is the deployment source of truth. It
declares TWO environments — development and staging, both in AWS region
eu-west-1. Development runs four
services out of this one monorepo plus a dedicated cache; staging runs the web
app and its cache only (the docs and Storybook are review surfaces, published
from develop):
| Service | Type | Serves |
|---|---|---|
euro-league-web-* |
web — Docker (Dockerfile.web) on Fargate |
apps/web on port 3000 (pnpm --filter @v4/web start) |
euro-league-docs-web-* |
web — nixpacks on Fargate, 0.25 vCPU / 0.5 GB |
apps/docs as a server on port 4400 (pnpm --filter @v4/docs start), so the docs chat runs with the service's ANTHROPIC_API_KEY |
euro-league-docs-* |
static — nixpacks |
apps/docs/out (built with DOCS_STATIC_EXPORT=1; no chat), until the docs' domain moves to the web service |
euro-league-storybook-* |
static — nixpacks |
apps/storybook/storybook-static |
euro-league-cache-* |
elasticache |
this client's OWN Redis |
Runtime sizes. The web service runs on 1 vCPU / 2 GB, between 1 and 2 instances,
autoscaling at 70% CPU or 70% memory or 500 requests per target, with a 300 s cooldown;
enableCloudfrontSwr is false, and the load balancer probes /api/health. The cache is
Redis 7.1 on cache.t4g.micro, no replicas, encrypted at rest, allkeys-lru (below).
| Environment | Branch | Trigger | Contentful environment | Migrated by |
|---|---|---|---|---|
development |
develop |
on push | develop |
model-apply-develop.yml (merge) |
staging |
release/0.4.0 |
on push | staging |
its own build (the step below) |
development tracks develop, the integration branch of the
release train (develop is integration, main is
released — cut by dispatching release.yml). staging (added 14 Oct 2026)
tracks the release branch being readied, and is the development block's web
and cache services with their own ids — the recipe for any further
environment. The promoted environment, when it is declared, deploys from main.
Staging's web build migrates the model first. staging is a PROTECTED
Contentful environment: the Actions migrate develop only, so nothing moves the
staging model but its own deploy pipeline, per the deployment contract
(The model deploy pipeline). Its build command
therefore runs CI=true ALLOW_PROTECTED_ENV=1 node packages/model/scripts/migrate.mjs --env=staging
ahead of the Next build, so the build bakes against the model the pipeline has
just brought up to date; a failing migration fails the build and the running app
is untouched. development has no such step. The migrate step needs
CONTENTFUL_CMA_TOKEN in the environment at build, which is why the first deploy
of a freshly declared environment fails until its variables are set.
For release 0.4.0 the build also runs two data scripts, between the migrations — a
release-specific step that comes out of flightcontrol.json with the next release.
It migrates only up to club/0059 (migrate.mjs --until=club/0059-game-team-sides),
then backfill-article-competition.mjs gives the seeded demo articles a Competition and
merge-players-into-profile.mjs copies the players into Basketball 🏀 Player and fills each
article's Players from its Associated Players. club/0060 deletes Associated Players, so it must
not run before the copy: it is held back to the next release, whose build drops --until and the
two scripts together. Both scripts run before the Next build so the new site goes live with the
data it reads, and a second run finds nothing to do.
Install and build
The @stadionhq-v3/platform-video-sdk-* packages (pulled in by @v4/media) and
@dicetechnology/vesper-sdk-web are PRIVATE, so every service — docs and Storybook
included, because the workspace install is whole-repo — writes an .npmrc from
NPM_TOKEN before pnpm install --frozen-lockfile, and corepack prepare pnpm@10.19.0
pins the pnpm the packageManager field names. The two services do it differently:
- web builds from
Dockerfile.web(node:24.22.1, pinned for a CVE fix — nixpacks could pin only the major). The Dockerfile installs with--prod=false(the service setsNODE_ENV=production, which would otherwise skipturboand the other devDependencies), then runs${BUILD_COMMAND}, and starts withpnpm --filter @v4/web start. There is nobuildCommandon the web services: the command is theBUILD_COMMANDservice variable, passed to the Dockerfile. - docs and Storybook are nixpacks builds with an
installCommandand abuildCommand(pnpm turbo run build --filter=@v4/docs/@v4/storybook). The static docs service prefixes it withDOCS_STATIC_EXPORT=1; the docs web service builds a server and starts it withpnpm --filter @v4/docs start. nixpacks does not readpackageManageron its own, hence the corepack step.
Build resources
Every service pins its own ci block, and all three are DOWNSIZES from what
Flightcontrol picks on its own — its EC2 runner defaults to an optimised
16 vCPU / 64 GB instance, which nothing here needs.
| Service | Build runner |
|---|---|
| web | m6a.xlarge (4 / 16 GB) |
| docs | m6a.large (2 / 8 GB) |
| Storybook | m6a.large (2 / 8 GB), 80 GB instance storage |
The web tier is 16 GB rather than 8 because apps/web's build script caps
the heap at 12 GB (--max-old-space-size=12288), and a heap cap at the
machine's whole RAM OOMs anyway. The service-level NODE_OPTIONS is also 12 GB,
raised from 6 GB on 23 Oct 2026 when a production build of apps/web measured
~15 GB RSS on the 16 GB builder: it covers the sibling package builds in the same
turbo run, while @v4/web's own script sets its cap for the Next build itself.
The cap was 8 GB until 14 Oct 2026, when staging's FIRST build died at it
("Ineffective mark-compacts near heap limit", exit 134). A cold build — no
webpack cache in the .next/cache mount yet — peaks at 10–11 GB (measured
locally with a 20 GB cap); Development had only ever built warm, which is what
kept 8 GB passing there. Most of that heap was theme CSS emitted several times
over; that is now fixed — the component stylesheets are precompiled in each
package's build and next.config.ts sets PH_THEME_EMISSION=club, so the club
theme is the one emitter — and the 12 GB cap stays as headroom.
Nothing sits on CodeBuild — it is Flightcontrol's legacy runner, slower and
dearer, and its standard environment is 7 GB, under what the web build needs.
m6a.large for Storybook is the first knob to turn if that build OOMs; 386
components across four packages is not a small Vite build.
The cache handler is wired at build; the Redis URL is resolved at runtime
The two live on DIFFERENT clocks, and conflating them was a real bug. Under
cacheComponents, next.config.ts registers use-cache-handler.mjs as
cacheHandlers.default — the NEW handler API (use cache never consults the
legacy singular cacheHandler). That registration is decided at build, so it
must gate on a build-STABLE signal:
- the config wires the handler whenever the build is not on Vercel
(
process.env.VERCELunset) — i.e. any self-hosted deploy. A Vercel client uses the platform's native shared cache and is never wired; - the handler resolves
CACHE_REDIS_URLat RUNTIME (bracket + lazy). On Flightcontrol that URL comes from the ElastiCache service viafromService, which is present only at runtime — absent at build. The earlier design gated the whole config block onclubConfig.hosting.redis(derived fromCACHE_REDIS_URL), so at build the gate was false and the handler was never registered; the app silently ran per-instance and one webhookrevalidateTagreached only the replica that received it (the intermittent ~120s staleness).
With no CACHE_REDIS_URL at runtime (CI, local dev, a self-hosted single-instance
client) the handler degrades to the built-in per-instance cache — never a failure.
evictionPolicy is allkeys-lru, deliberately. The handler writes a
tags-manifest hash (usecache:tagman) with NO TTL. Under ElastiCache's default
volatile-lru nothing is ever evictable, so the node fills and writes start
failing — the "0 keys / full memory" shape already seen on another client's
store. allkeys-lru lets the cache behave like a cache.
Isolation stays per client by ruling: this Redis serves EuroLeague only. The
manifest uses a FIXED key (no CACHE_PREFIX) — the separate per-client Redis is
the isolation boundary, and a prefix could not be sourced consistently across
Next's bundled vs raw-module realms.
Env vars
Values live in the Flightcontrol dashboard (Environment settings →
Environment variables), not in this file. flightcontrol.json declares only
the wiring that cannot be expressed there:
"CACHE_REDIS_URL": { "fromService": { "id": "euro-league-cache-development", "value": "connectionString" } } // -staging in the staging block
Do NOT reintroduce fromParameterStore for these. It resolves a literal AWS
SSM Parameter Store path, NOT a dashboard variable of the same name — a path
nothing created fails the build at "Preparing build environment", before a
single line of the install runs, and the error names the parameter rather
than the mistake.
What the app needs present, whatever sets it:
| Var | Needed by |
|---|---|
NPM_TOKEN |
install — all three services |
CONTENTFUL_ACCESS_TOKEN |
the build; club.config.ts names it |
CONTENTFUL_SPACE_ID |
the web app (club.config.ts reads the space from it) and the model scripts — required at build and run |
CONTENTFUL_CMA_TOKEN |
the web BUILD_COMMAND provisioning (sync-webhooks/preview) and, on staging, the migrations and data scripts |
CONTENTFUL_ENVIRONMENT |
which Contentful env this app reads AND the webhook targets |
CONTENTFUL_WEBHOOK_SECRET |
POST /api/revalidate + the webhook the build provisions |
NEXT_PUBLIC_SITE_BASE_URL |
canonical URLs, sitemap, OG metadata (the public/CDN URL) |
LOAD_BALANCER_URL |
webhook + preview provisioning target (origin URL — see below) |
NEXT_PUBLIC_VIDEO_API_BASE_URL |
the video domain |
NEXT_PUBLIC_BRIGHTCOVE_ACCOUNT_ID |
the video domain |
COMPETITION_ENGINE_BASE_URL |
the basketball data areas — the Competition Engine origin. Defaults to http://localhost:8080, so a deployed environment must set it |
BASKETBALL_ROUTES |
optional switch — false darkens the data areas (pages and basketball APIs 404, no sitemap entries, no crest strip). The header and sitemap are cached at build: set it at BUILD and at runtime |
GAME_CENTRE_REPLAY |
development aid only — on lets a finished game replay as live (?replay=N). Never on production |
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID |
edge purge-on-publish (optional — both unset = no edge purge; see Caching) |
NEXT_PUBLIC_GTM_CONTAINER_ID |
measurement — ELB's Google Tag Manager container (optional; shape-checked GTM-…) |
NEXT_PUBLIC_ONETRUST_DOMAIN_SCRIPT_ID |
consent — the OneTrust banner (optional; unset = no banner and no events) |
NEXT_PUBLIC_WSC_TRACKING_PREVIEW |
development only — lets hand-granted consent send WSC events for QA |
NODE_ENV, NODE_OPTIONS |
service variables in flightcontrol.json — production and the 12 GB heap |
NEXT_PUBLIC_WSC_API_KEY |
WSC Stories / Moments (optional — unset or blank = no WSC rows; the Dev and Prod keys are in 1Password) |
NEXT_PUBLIC_WSC_ENVIRONMENT |
the WSC stage of that key — prod (default) or uat; leave it unset, never empty (with a key set, an empty value fails the build) |
NEXT_PUBLIC_VESPER_API_KEY |
ELTV livestream (optional — unset or blank = no player, no SDK download; values in 1Password) |
NEXT_PUBLIC_VESPER_BASE_URL |
ELTV livestream — the Vesper frontoffice origin for that key's realm (required once the key is set) |
NEXT_PUBLIC_VESPER_ENVIRONMENT |
ELTV livestream — stag or prod, no default (required once the key is set) |
NEXT_PUBLIC_ELTV_WATCH_URL |
ELTV livestream — the ELTV site for link-outs (required once the key is set) |
NEXT_PUBLIC_ELTV_LOGIN_URL |
ELTV livestream — the login page its "Log in to watch" link opens (optional) |
NEXT_PUBLIC_ELTV_ACCESS_URL |
ELTV livestream — the livestream access endpoint template (optional; unset = no player until the backend exists) |
NEXT_PUBLIC_ECAL_API_KEY |
ECAL "Add to calendar" (optional — unset or blank = no button and no ECAL script; the key is in ELB's embed code and ECAL Admin) |
CORTEX_SSO_BASE_URL |
Cortex sign-on (optional — unset = no Login control and /api/auth/* answers 404). Once set, the next five are required, at BUILD and at runtime. See Single sign-on |
CORTEX_MICROSITE_URL |
Cortex sign-on — the hosted login and account site |
CORTEX_SSO_CLIENT_ID |
Cortex sign-on — EUROLEAGUE |
CORTEX_SSO_ISSUER |
Cortex sign-on — the Issuer set for the client in Cortex's dashboard |
NEXTAUTH_URL |
Cortex sign-on — this site's public address (the callback and logout-return addresses are built from it) |
NEXTAUTH_SECRET |
Cortex sign-on — random, unique per environment; a secret |
AUTH_SESSION_MAX_AGE_SECONDS |
Cortex sign-on — session length in seconds (optional, default 86400) |
NEXT_PUBLIC_ROBOTS_ALLOW |
crawling switch — unset/false = no crawl (default), true = allow |
CONTENTFUL_PREVIEW_TOKEN |
draft preview (optional — empty = preview off) |
CONTENTFUL_PREVIEW_SECRET |
draft preview + gates the build's sync-preview-urls |
Two ELTV constraints decide WHEN to set the Vesper vars on a deployed environment:
Deltatre must first register that environment's hostname in the realm's
domains (the production realm lists only the EuroLeague hosts; staging adds
localhost), or the SDK's frontoffice calls are refused by CORS; and the
production realm runs ads, so the key must not be set on production unless
consent is on: OneTrust consent is now wired (@v4/analytics, features.consent in
club.config.ts, on when NEXT_PUBLIC_ONETRUST_DOMAIN_SCRIPT_ID is set) — check
both are set together. NPM_TOKEN must also be able to read the private
@dicetechnology/vesper-sdk-web package.
Provisioning runs in the buildCommand, not postDeployCommand (moved
2026-09-15). sync-webhooks + sync-preview-urls run at the end of the web
build, non-fatally (… ; true). The move exists because the post-deploy shell
did not receive the runtime env vars — the same command that provisions
correctly from a laptop no-op'd in post-deploy because CONTENTFUL_CMA_TOKEN was
absent there, and a post-deploy failure is non-fatal so the deploy stayed green
with an empty space (two silent misses before it was found). The build, by
contrast, has every env var (includeEnvVariablesInBuild: true) and streams
its logs, so a provisioning error is visible. sync-preview-urls is still
skipped unless CONTENTFUL_PREVIEW_SECRET is set.
The provisioning target is LOAD_BALANCER_URL, not the public URL. Both
scripts resolve the base URL as LOAD_BALANCER_URL → SITE_BASE_URL →
NEXT_PUBLIC_SITE_BASE_URL. The webhook (/api/revalidate) and the preview
"Open preview" link (/api/preview) must hit the origin (the Flightcontrol
load-balancer *.fctl.app URL) directly — they are dynamic route handlers,
so pointing them at the CloudFront domain would route cache-fronted requests at
uncacheable endpoints. NEXT_PUBLIC_SITE_BASE_URL stays the public/CDN URL for
canonical + OG + sitemap.
A missing base URL still leaves the space empty. With none of the three set,
both scripts throw no site base URL on their first line. Provisioning is
non-fatal (… ; true), so the deploy stays green — but now the throw prints in
the build log, so the failure is visible rather than silent. Either way the
tell is the space, not the deployment status: verify with a CMA GET /spaces/<space>/webhook_definitions + /preview_environments after enabling,
never by trusting the green tick. (This is also why an unset
NEXT_PUBLIC_SITE_BASE_URL is doubly dangerous — see the quiet-localhost note
below.)
NEXT_PUBLIC_SITE_BASE_URL is the one that fails QUIETLY: club.config.ts
defaults it to http://localhost:3000, so an unset var builds green and
ships localhost in every canonical URL, the sitemap and every OG tag.
CACHE_REDIS_URL must not be set by hand, in the dashboard or anywhere
else. It comes from the cache service via fromService, which re-resolves
when the cluster's endpoint changes; a typed-in endpoint silently goes stale
the first time the cache is replaced.
Health check
GET /api/health answers from the running process alone — no Contentful, no
Redis, no video API. A probe that calls a third party turns THEIR outage into
a rolling task restart; dependency failures belong on the pages that use
them, where they degrade instead of draining the service.
It answers { "status": "ok", "uptime": <s>, "cache": "memory" | "redis" } with
no-store. Do not trust the cache field on Flightcontrol. It reads
clubConfig.hosting.redis, which is derived from CACHE_REDIS_URL at BUILD time —
absent on Flightcontrol, where the URL arrives at runtime — so it reports "memory"
even though the handler is wired (the route's own comment describes the pre-fix
design). To check the shared cache, publish and watch a page refresh on every replica,
or read the handler's logs.
Deploy reporting
deploy-watch.ymllistens to thedeployment_statusevents Flightcontrol already reports to GitHub, so a broken web build — which CI does not build, because it needs a delivery token — is reported when the DEPLOY fails. It runs as it is in the deployed commit, not on the default branch.release-build.ymltreats every push to arelease/X.Y.Zbranch as a staging build: it tags itvX.Y.Z-rc.N, publishes a GitHub pre-release and keeps Jira's Fix Version in step (scripts/ci/release-jira.mjs).
See GitHub Actions.
Single sign-on: Cortex
Visitors sign in with their EuroLeague account, held by Cortex (FanScore). The site never sees a password: login, registration, password reset and email verification all happen on Cortex's hosted microsite, and the visitor comes back signed in.
The code is @v4/cortex-sso (packages/cortex-sso) plus five small routes under
apps/web/src/app/api/auth/. The plan, the rulings behind it and what the stage test found are in
.claude/reference/SSO/sso-mvp-plan.md.
What a visitor gets
| Place | Signed out | Signed in |
|---|---|---|
| Desktop header | "Login" button | Their first-name initial in a circle, linking to their account page |
| Compact header account icon | Starts the login | The same initial circle |
| Menu, at the foot | "Login" button | "My Account" button, linking to their account page |
The account page is on the microsite. There is no Sign out on this site in this pass: a visitor signs out on the microsite, which sends them back here signed out (see "How a logout works").
Signing in changes the header and nothing else. No page is protected, no content is gated, and nothing is personalised.
Switched on by its variables
Sign-on is part of the site, and every developer runs with it on: .env.example holds the stage
values, ready to use as they are.
CORTEX_SSO_BASE_URL is the switch. Where it is absent, the header has no Login control and every
/api/auth/* route answers 404. That is how CI builds, which has none of these variables, and it
is what keeps an environment that Cortex has not been set up for from offering a login that cannot
work.
| Variable | Value on stage (develop) | Notes |
|---|---|---|
CORTEX_SSO_BASE_URL |
https://oauth-stage.fanscore.com |
Cortex's OAuth platform. Production: https://oauth.fanscore.com |
CORTEX_MICROSITE_URL |
https://dev.d25m1wdqo9lwzf.amplifyapp.com |
The hosted login and account site |
CORTEX_SSO_CLIENT_ID |
EUROLEAGUE |
|
CORTEX_SSO_ISSUER |
https://dev.d25m1wdqo9lwzf.amplifyapp.com/ |
Must equal the token's iss exactly, trailing slash included |
NEXTAUTH_URL |
https://euroleague-basketball-dev.stadion.io |
This site's public address. Locally: http://localhost:3000 |
NEXTAUTH_SECRET |
random, unique per environment | The only secret. openssl rand -hex 32 |
AUTH_SESSION_MAX_AGE_SECONDS |
optional, default 86400 |
How long a login lasts, counted from the login |
Once the switch is set, the next five are required and a missing one fails by name. They are read when the site's config loads, which happens at build as well as at runtime: set them for the Flightcontrol build too, or the site builds with sign-on off.
Locally, copy the Cortex lines from .env.example into your settings as they are and run the site
on port 3000. The NEXTAUTH_SECRET there is a local-only value, not a secret; deployed
environments each set their own. Everyone shares the one stage client, so nothing needs registering per developer.
What has to be set in Cortex's dashboard
Per environment, for client EUROLEAGUE. Both lists match exactly: a trailing slash or a stray
space is enough to fail.
| Setting | Value |
|---|---|
| Issuer | The microsite's address. Cortex accepts a login request only from the site set here |
| Redirect URIs | <site>/api/auth/callback/cortex |
| Post logout redirect URIs | <site>/api/auth/logout/return |
| The microsite's main site | <site>. Where the microsite sends a visitor after a logout made there |
| Account Verification email template | Its link points at <microsite>/verify, with the template's own code and id values |
<site> is http://localhost:3000 for local use and https://euroleague-basketball-dev.stadion.io
on develop. Never register a localhost address on production.
How a login works
- The Login button posts to NextAuth, which creates the state, nonce and PKCE values and sends the browser to the microsite.
- The visitor signs in there. The microsite returns to
/api/auth/callback/cortexwith a code. - On the server, the code is exchanged for tokens and the ID token is verified (signature, issuer, audience, expiry, nonce). The access and refresh tokens are then discarded.
- The session cookie is set and NextAuth sends the browser to
/api/auth/login/return, which sets theelb-signed-inhint (only when a session cookie came with the request, so visiting the address by hand marks nobody) and redirects to the page they started from (returnTo, kept to a safe site path).
The client is public: PKCE, no client secret.
A login that fails lands on the home page with ?loginfailed=true (plus NextAuth's own error
code). Nothing reads that parameter yet; it is there so a message can be shown later.
The session
- Where it lives: NextAuth's encrypted, http-only cookie —
__Secure-next-auth.session-tokenon https,next-auth.session-tokenotherwise (packages/cortex-sso/src/cookies.ts). There is no database and no session store, so it works across every web instance. - What it holds: the account id, the first name, whether the email is verified, and the ID token (needed to log out). No other profile data is stored, but the ID token itself carries the claims Cortex issued at login, the email and last name among them.
- What the browser can read: the first name, the account id (
userId, the ID token'ssub) and the expiry, from/api/auth/session. The id is there for measurement: every event the site sends carries it asuser_id, taken as the tracking plan's "current SSO ID" until ELB confirms which InCrowd id it wants. - How long it lasts: 24 hours from the login, whatever the activity. Change it with
AUTH_SESSION_MAX_AGE_SECONDS.
Pages stay cached
The app runs cacheComponents, and a page that read the session on the server would become
per-visitor and uncacheable. So:
- No server component reads the session. Never call
getServerSession()orcookies()in a layout or page for this. Only the/api/auth/*routes read the cookies. - The header renders signed out on the server and on first paint, then swaps in the browser.
- An anonymous page view makes no session request at all. At login a small readable cookie,
elb-signed-in=1, is set. It carries no identity. The header checks the session only when that cookie is present (useAccountSession), once per page load. - NextAuth's
SessionProvideris not used, and NextAuth's middleware must not be applied to pages.
Cloudflare must not cache /api/auth/*.
How a logout works
From this site. No control calls this yet (the header has no Sign out in this pass), but the
route is in place and tested. Sign out posts to /api/auth/logout. The route clears our cookies first, so
the sign-out always takes effect here, then sends the browser to the microsite's logout page, which
ends the microsite's session and returns to /api/auth/logout/return. The page they left is kept
meanwhile in a short-lived elb-logout-return cookie (5 minutes), so the visitor ends on that page.
From the microsite. This is how a visitor signs out today. The microsite sends the visitor to its main-site address with
?signout=true. The header reads that, ends our session (/api/auth/logout/local) and removes the
parameter from the address.
Two limits to know:
- A visitor who signs out on the microsite and does not come back through that redirect stays signed in here until the session expires.
- If Cortex refuses a logout (almost always a return address that is not registered), the microsite sends the visitor to its main site instead of back to the page they left. They are still signed out here.
Things Cortex does that we do not control
- Email verification is enforced. An unverified account is stopped on the microsite at "Verify
your email address" and is not returned to us until it verifies. The link in that email is set in
Cortex's Account Verification email template, per environment: it must point at the microsite's
own page,
<microsite>/verify?code=…&id=…, which verifies the account and then continues the login that was in progress. On stage it does (set 12 Oct 2026); production needs the same. - The first name arrives in the ID token as
first_name, not the standardgiven_name. The package reads both.
Testing a real login
packages/cortex-sso/stage-test is a small standalone app that mounts the same routes and hook as
the site, for testing a real login against stage without running the whole site:
pnpm --filter @v4/cortex-sso stage-test
Its README lists the journeys to try and what the common failures mean.
When a feature needs to know who the visitor is
Entitlements, gated articles or a protected page break the "no server component reads the session" rule, and must be designed, not bolted on:
- Read the session only inside the part of the page that needs it, behind its own Suspense
boundary, or serve that part from an
/api/route the browser calls. - Protected routes get a route guard in middleware, scoped to those paths only.
- Entitlements need a token kept on the server, which brings token storage and refresh into scope. That is also the point to move sessions into Redis and add full single logout.
The first such feature is the Code of Conduct report (@v4/code-of-conduct). Its page
(apps/web/src/app/[locale]/code-of-conduct/page.tsx) is handed the microsite's register and account
addresses (micrositeRegisterUrl, micrositeAccountUrl), and in the browser it decides what the
visitor SEES with the same useAccountSession hook and startLogin as the header
(packages/code-of-conduct/src/page/code-of-conduct-page.tsx). What it SENDS comes from an /api/
route, never from the page: readSessionProfile (@v4/cortex-sso/server)
returns the first name, last name and email from the session's ID token, or null with no live
session. The report sends that name and email to ELB, so whoever replies to the reporter has an
address to reply to. That is a new use of the email, and the privacy notice should say so.