Web app

Reference for apps/web: what it owns, its middleware and API routes, link resolution, caching, hosting and sign-in.

2026-11-07

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.ts domains: ["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, standings and stats, 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 in src/lib/competition-engine.ts.

    Each exports per-route FACTORIES (createNewsArticleRoute(deps)).

  • apps/web is shims + chrome. pnpm compose reads each package's routes.json and generates one shim per route under src/app/[locale]/**/page.tsx that wires the factory to the app's client / 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 page src/app/[locale]/qa-922b04feaf/error-500/page.tsx (never indexed). The other 34 of the 36 page.tsx files 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 (tagged content:siteSettings, content:navigation) and each competition's chrome, which also carries content:competition. Both are rendered by @v4/ghost-core components.

Locales and site sections

  • Locale routing is by path segment (locales.routing: "segment" in club.config.ts), so every page lives under src/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.ts sections and resolved by @v4/model core/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:

  1. answers the basketball API routes (/api/players|stats|games|live/…) with a 404 when BASKETBALL_ROUTES=false, and lets them through otherwise;
  2. 308-redirects an address with no locale segment;
  3. rewrites a data-area page to the not-found page, with a 404, when the basketball routes are switched off;
  4. marks the QA error page X-Robots-Tag noindex;
  5. sets a Cloudflare Cache-Tag header per page from src/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 by next.config.ts. next.config.ts reads it only for the image hosts and the client's name (the cache key prefix); the per-client use cache handler is wired whenever the app is NOT on Vercel (process.env.VERCEL), never on the hosting block.
  • 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.ts and tokens-basketball.css.ts (imported by the /video and 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>) and fonts.css.ts (the brand faces). next.config.ts sets PH_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_TOKEN set, a request in Next draft mode is served live from the Contentful preview client, bypassing the cache (src/lib/content.ts); /api/preview checks CONTENTFUL_PREVIEW_SECRET and that the draft exists before enabling draft mode. The preview URLs in Contentful are written by packages/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.xml with chunked children (src/app/sitemaps/sitemap.ts) and robots.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 when BASKETBALL_ROUTES=false. robots allows crawling only when NEXT_PUBLIC_ROBOTS_ALLOW=true. global-error.tsx is the root error boundary.

Reading order

  1. 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/model package (Model package).

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 ce tag 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) and computed (standings, leaders, stats, records) 60/300/3600; games (the schedule) and gameDetail 30/60/3600; played (a finished game) 60/300/3600.
  • Failures are cached too, briefly: FAILED_LIFE 0/30/300, so a down engine is asked again after half a minute. Each request times out after ENGINE_TIMEOUT_MS (10 s).
  • Live endpoints (the live Game Centre, the live scoreboard) use noStore and 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 cachedEngineGet with a CE_LIFE class and CE_TAG tags; a live endpoint uses noStore. Never a bare fetch to the engine.
  • The app owns the cache boundary. Under cacheComponents: true the model client is wrapped in a use cache function (apps/web/src/lib/content.ts) that applies the descriptor's tags via cacheTag() and its profile via cacheLife(). 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_URL is a runtime fromService binding 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.ts still passes env: { 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/1 redirects 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 trailing page/<n>. The old /filter/ addresses only redirect (content-app lib/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 sets NODE_ENV=production, which would otherwise skip turbo and the other devDependencies), then runs ${BUILD_COMMAND}, and starts with pnpm --filter @v4/web start. There is no buildCommand on the web services: the command is the BUILD_COMMAND service variable, passed to the Dockerfile.
  • docs and Storybook are nixpacks builds with an installCommand and a buildCommand (pnpm turbo run build --filter=@v4/docs / @v4/storybook). The static docs service prefixes it with DOCS_STATIC_EXPORT=1; the docs web service builds a server and starts it with pnpm --filter @v4/docs start. nixpacks does not read packageManager on 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:

  1. the config wires the handler whenever the build is not on Vercel (process.env.VERCEL unset) — i.e. any self-hosted deploy. A Vercel client uses the platform's native shared cache and is never wired;
  2. the handler resolves CACHE_REDIS_URL at RUNTIME (bracket + lazy). On Flightcontrol that URL comes from the ElastiCache service via fromService, which is present only at runtime — absent at build. The earlier design gated the whole config block on clubConfig.hosting.redis (derived from CACHE_REDIS_URL), so at build the gate was false and the handler was never registered; the app silently ran per-instance and one webhook revalidateTag reached 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.yml listens to the deployment_status events 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.yml treats every push to a release/X.Y.Z branch as a staging build: it tags it vX.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

  1. The Login button posts to NextAuth, which creates the state, nonce and PKCE values and sends the browser to the microsite.
  2. The visitor signs in there. The microsite returns to /api/auth/callback/cortex with a code.
  3. 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.
  4. The session cookie is set and NextAuth sends the browser to /api/auth/login/return, which sets the elb-signed-in hint (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-token on https, next-auth.session-token otherwise (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's sub) and the expiry, from /api/auth/session. The id is there for measurement: every event the site sends carries it as user_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() or cookies() 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 SessionProvider is 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 standard given_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.