App and rendering
How the Next.js app renders pages: route factories, the data flow from each source, theme emission and caching.
The web app
apps/web is the Euro League website — the Next.js app that is deployed — configured
by one typed file (apps/web/club.config.ts). The app itself is deliberately thin: it composes packages.
It writes no component markup and no raw Contentful queries of its own.
The runtime
| Layer | Choice |
|---|---|
| Framework | Next 16 (App Router) |
| UI | React 19, React Server Components by default |
| Language | TypeScript 5.9, ESLint 9 strict type-aware, zero warnings |
| Styling | vanilla-extract — CSS is emitted at build time, never at runtime |
| Caching | cacheComponents: true — use cache boundaries, tags, PPR shells |
| Dev | next dev --webpack on :3000 |
Rendering is server-first. Pages are React Server Components: content is fetched and transformed on the server, and only the pieces that genuinely need interactivity (a media player, a menu, a focus-trapped dialog) are client components. Dictionary copy and content transforms resolve in the server layer and pass down as plain string props, so the client bundle carries data, not fetching logic.
What the app owns
The app is the composition root and little else. Its responsibilities are:
- Configuration —
club.config.ts(defineClubConfig()) declares the club's identity, locales, competition sections, feature modules, the Competition Engine and image providers, and the boughtdomains. - Theme emission — the club theme is the one emitter of the
--ph__*layer (PH_THEME_EMISSION=club, set innext.config.ts) and is emitted in parts:src/theme/tokens.css.tson every page (the global layer on:root, plus the ghost-core and ghost-content themes),tokens-home-hero.css.tsandcompetition-themes.css.tsfrom the locale layout,tokens-video.css.tsfrom the/videoand data-area layouts,tokens-basketball.css.tsfrom the data-area, Code of Conduct and article layouts. A route that renders a package must load its part. See Theme emission and performance. - Route mounting — the App Router tree under
apps/web/src/app/[locale]/**is mostly generated shims and boundary layouts that mount the route factories, beside a few hand-written pages (code-of-conduct, the QA error page) and the route handlers insrc/app/api/(revalidate,preview,disable-preview,download/[assetId],geolocation,health,live/games,games/live,games/round,players/search,players/hub,stats/teams,auth/*,code-of-conduct/*). - Middleware —
src/middleware.tsredirects an address with no locale segment to the default locale's (/news→/en/news), answers 404 for the basketball areas whileBASKETBALL_ROUTES=false, and sets the CloudflareCache-Tagheader per page. - The root layout —
app/[locale]/layout.tsximports the theme parts and the configuration modules for their side effects, and wraps every page in the providers:AnalyticsProvider,EcalProvider,WscProvider,SiteChrome(header, footer, competition chrome, the club-crest strip),CompetitionThemeSync,SoftNavigation,ScrollManagerand React'sViewTransition(the page cross-fade). - Client wiring — the
src/lib/*modules build the per-app singletons the factories depend on: the Contentful clients (content.ts:contentClient, preview-aware, anddeliveryContentClient, delivery-only and cached),media.ts,competition-engine.ts,images.ts, the chrome and sections (chrome.ts,sections.generated.ts,current-section.ts,section-nav.ts), caching (revalidation.ts,cache-tags.ts,cloudflare-purge.ts), locale routing, the integrations (analytics.ts,auth.ts,ecal.ts,eltv.ts,wsc.ts), the widgets (article-header-widgets.ts,home-hero-widgets.ts,team-strip.ts),motion-ready.tsandsitemaps.ts.
Everything else is a package
Components come from @v4/ghost-core and the domain packages; routes come from
@v4/content-app, @v4/video-app and @v4/basketball-app; content comes
through @v4/model fragments; video through @v4/media; competition data
through @v4/competition-engine. Those are all workspace:* links, so there is
no publish step between the app and what it renders — the route packages are
transpiled in place, and the component packages are read from their built
dist/ (rebuild one, then restart next dev).
The pages that follow trace this: how the App Router mounts the route factories
through pnpm compose, how data flows from its three sources to the rendered
component, how the theme is emitted, and how pages are cached.
Routes and factories
A domain's routes do not live in apps/web. They live in a route-factory
package — @v4/content-app for the content domain, @v4/video-app for the
video domain and @v4/basketball-app for this project's own basketball domain —
and the app mounts them through generated shims. This is what lets a whole domain
be added or dropped by configuration rather than by editing the App Router.
Per-route factories
Each package exports one factory per route. A factory takes the app's
dependencies and returns the pieces the App Router needs: a Page component, a
generateMetadata function and, for a route that prerenders its addresses, a
generateStaticParams. The dependency vocabulary (the deps of a
routes.json entry) is:
| Dep | Wired as | Used by |
|---|---|---|
client |
client: contentClient (preview-aware) |
content and video routes |
deliveryClient |
client: deliveryContentClient (delivery-only) |
every basketball route — nothing there is previewed, so pages can be cached |
buildClient |
buildClient: deliveryContentClient |
what a route reads at build time (generateStaticParams), e.g. news-path |
locale |
locale: clubConfig.locales.default |
every route |
media |
media: mediaSettings |
video routes, and the basketball areas that show video |
competitionEngine |
competitionEngine: competitionEngineSettings |
every basketball route |
// @v4/content-app
(createHomeRoute,
createNewsListingRoute,
createNewsCategoryRoute,
createNewsTagRoute,
createNewsListingBySlugRoute,
createNewsPathRoute, // news/[...path]: articles and competition news listings
// (delegates an article to createNewsArticleRoute)
createContentPageRoute); // [...path]: every other content page
// @v4/video-app
(createVideoLandingRoute,
createVideoDetailRoute,
createVideoCollectionRoute,
createVideoPlaylistRoute,
createVideoCategoryRoute);
// @v4/basketball-app
(createTeamsRoute,
createPlayersRoute,
createCoachesRoute,
createGamesRoute,
createStandingsRoute,
createStatsRoute);
The route packages are raw source and own their own Contentful page queries and codegen — the app transpiles their page bodies in place.
Generated shims
pnpm compose (the compose-club generator in @v4/template-config) reads
apps/web/club.config.ts and each bought domain's routes.json, and writes an
App Router file per route under apps/web/src/app/[locale]/ (every address
carries its locale segment — locales.routing: "segment"). The shims are thin and
generated — never hand-edited — and drift fails CI. This is the real shim for a
team's, or a competition's teams list's, address:
// apps/web/src/app/[locale]/teams/[...path]/page.tsx
// GENERATED by compose-club — do not edit. route:teams-path (basketball domain, @v4/basketball-app)
// Regenerate with `pnpm compose`; drift fails CI.
import { createTeamsRoute } from "@v4/basketball-app/teams";
import { Suspense, type ReactNode } from "react";
import clubConfig from "../../../../../club.config";
import { competitionEngineSettings } from "../../../../lib/competition-engine";
import { deliveryContentClient } from "../../../../lib/content";
import { mediaSettings } from "../../../../lib/media";
import { TeamPathLoading as Loading } from "@v4/basketball-app/loading";
const route = createTeamsRoute({
client: deliveryContentClient,
locale: clubConfig.locales.default,
competitionEngine: competitionEngineSettings,
media: mediaSettings,
});
export default function Page(props: Parameters<typeof route.Page>[0]): ReactNode {
return (
<Suspense fallback={<Loading locale={clubConfig.locales.default} />}>
<RoutePageBody {...props} />
</Suspense>
);
}
async function RoutePageBody(props: Parameters<typeof route.Page>[0]): Promise<ReactNode> {
return route.Page(props);
}
export const generateMetadata = route.generateMetadata;
export const generateStaticParams = route.generateStaticParams;
The loading entry in routes.json names the fallback (TeamPathLoading, a
skeleton of the page): the shim renders it as its <Suspense> fallback, and a
generated loading.tsx beside the shim exports it as the segment's. A route
without one falls back to the generic full-viewport placeholder,
app/[locale]/route-loading.tsx.
"staticParams": true adds the generateStaticParams export.
A route that reads params (or fetches the uncached video API) streams its page
body behind that <Suspense>, so the prerendered shell holds the full-viewport
placeholder until the content arrives, never an empty
gap with the footer pulled up under the header. The home and /news
shims keep the bare export default route.Page; form, and so does every News
route: their one boundary is a generated news/layout.tsx ("boundary": "news"
in routes.json), because a boundary below the part of the page a navigation
changes is re-created on every navigation and showed the placeholder each time
— on every filter, page and article within News. The guide
Add a page or route
covers when the generator emits which shape.
A video route shim is the same Suspense-wrapped shape with the client, media
and locale dependencies:
// apps/web/src/app/[locale]/video/page.tsx (excerpt)
const route = createVideoLandingRoute({
client: contentClient,
media: mediaSettings,
locale: clubConfig.locales.default,
});
Besides the page shims, compose writes the boundary layouts ("boundary" in
routes.json, e.g. app/[locale]/news/layout.tsx),
apps/web/src/lib/locale-routing.generated.ts (the locale segments) and
apps/web/src/lib/sections.generated.ts (the default competition and each
section's capabilities), and packages/model/composition.json.
Why the indirection
The shim is the seam between configuration and code. Because the route factories are packages and the mount points are generated:
- A domain is composed in, and its routes appear.
domainsinclub.config.tsdrives which shimscomposewrites; a domain not listed has no shims, no route package in the graph and no types in the schema. This site listscontent,videoandbasketball. - The wiring is auditable. The generator is drift-gated:
pnpm compose -- --checkruns in CI (and inpnpm verifybefore you push), so a hand-edited or stale shim reddens the build. - The factory stays testable in isolation. A route factory is a plain function over its dependencies, so it is exercised in its own package without the App Router around it.
So the app is a composition root whose route tree is generated from one config file, and a domain the site does not compose leaves no trace.
Data flow
Data travels one way, and each source is reached through exactly one package. The site's pages read three sources:
| Source | Reached only through | What it supplies |
|---|---|---|
| Contentful | @v4/model |
Editorial content, pages, navigation, dictionary copy, editorial overrides |
| The Stadion video platform | @v4/media |
Videos, collections, playlists, categories and their filters |
| ELB's Competition Engine | @v4/competition-engine |
Competitions, seasons, teams, players, coaches, games, standings, stats |
The route factory shapes the data into props, and the ghost component packages render it. No app or package touches a Contentful SDK, a raw GraphQL endpoint, the video SDK or the engine's HTTP API directly.
The path, end to end
Contentful ──▶ @v4/model fragments ──▶ page query (in the app/factory)
│
transforms (@v4/model)
│
route factory (createNewsPathRoute)
│
@v4/ghost-core / -content / -video / -basketball components
- Fragments —
@v4/modelowns a curated GraphQL fragment per component surface ({Surface}Fields). A fragment is the reviewed statement "this surface consumes exactly these fields": it prunes rather than selecting all, because GraphQL complexity limits are real. Field names are validated against the vendoredschema.graphqlby codegen, which refuses guesses. - Page queries — the page-level query lives in the app (or the route-factory
package), composed by spreading
@v4/modelfragments. An app never writes raw field selections for a model-owned surface; if a field is missing, the fragment is extended (reviewed), not selected inline. Collections always carry an explicitlimit. - Transforms —
@v4/modeltransforms turn the Contentful response into the normalised shapes components expect. Dictionary copy is resolved on the server withgetDictionary(locale)from@v4/model/dictionary— a cached read of the live labels, taggedcontent:dictionaryKey, so a published label reaches the site without a deploy. - The route factory composes the query, runs the transform, and hands the result to the real components as props — server-side, so the client receives data rather than fetching logic.
- Components render. The ghost packages paint the data through their typed theme contracts, so the same content renders in ELB's colours without any component change.
The single boundary
The rule that makes this tractable is that apps import everything
Contentful-shaped from @v4/model and nothing from Contentful SDKs directly.
Types are generated, never hand-written: after any .graphql edit,
pnpm --filter @v4/model generate regenerates them against the vendored schema.
The model is code — migrations, schema, types, fragments, transforms — so it
travels with the code that consumes it and is drift-gated in CI.
Video is a legitimately minimal Contentful surface
Not every domain reads its bulk data from Contentful. The video domain's data
reads come from the Stadion platform API through @v4/media — the only importer
of the platform video SDK — so its Contentful surface is deliberately thin: two
fragments in packages/model/src/video/fragments/ (video-landing-settings.graphql,
the default-landing pointer, and video-category-card.graphql, the landing's
category block) and the category-card transform at @v4/model/video. The video
routes also read each listed video's competition from its Video Item, one query
per list, because the platform's responses carry none.
Basketball blends the engine with editorial
The basketball areas read their data from the Competition Engine through
@v4/competition-engine and merge Contentful's editorial layer over it
(@v4/competition-engine/merge, over the @v4/model/basketball fragments —
team and player pictures, venues, hero slides, season sponsors). The rule is
editorial wins when set: a field an editor has filled replaces the engine's,
and an empty one leaves the engine's value. Engine reads are cached in a
use cache boundary by class, and the live endpoints are never cached on the
server — see Basketball and the Competition Engine.
The pattern holds across all three: each source has one owner, and the components at the end of the flow neither know nor care which package the data came from.
Theme emission and performance
The theme registry and its emitters are described on The theme registry. This page is what the app does with them, and why — the outcome of the Lighthouse campaign of 24–26 October 2026.
The club theme is the one emitter
Each ghost package's init.css.ts imports every component theme (that is what
registers them) and, in Storybook and the e2e, emits its own themes with the
Ghost defaults. In the app that would ship the defaults again under the same
selectors as the club's values, so apps/web/next.config.ts sets
PH_THEME_EMISSION=club where vanilla-extract evaluates the stylesheets. The
package bootstraps then register only, and the app's club theme emits every
theme exactly once with Euro League's values.
Emitted in parts, per route
One stylesheet holding every package's theme was about 3 MB and loaded on every page. The app now emits it in parts, each imported by the layout of the routes that render it:
| Part | Emits | Imported by |
|---|---|---|
src/theme/tokens.css.ts |
ELB's global layer on :root; ghost-core and ghost-content themes |
app/[locale]/layout.tsx (every page) and app/global-error.tsx |
src/theme/tokens-home-hero.css.ts |
The home hero widgets' ghost-basketball themes (HOME_HERO_THEMES, a hand-kept list — a widget added to the hero adds its themes here) |
app/[locale]/layout.tsx (the home routes have no layout of their own) |
src/theme/competition-themes.css.ts |
EuroCup and NextGen overrides on :root.<class> |
app/[locale]/layout.tsx |
src/theme/tokens-video.css.ts |
ghost-video themes | the video layout and every data-area layout |
src/theme/tokens-basketball.css.ts |
ghost-basketball themes | the data-area layouts, code-of-conduct/layout.tsx, news/[...path]/layout.tsx (article widgets) |
src/theme/fonts.css.ts |
The self-hosted brand @font-face rules |
app/[locale]/layout.tsx, app/global-error.tsx |
Each package part calls createClubThemePart(tokens, { components: OWN_THEMES })
with the package's own list, so a theme is emitted once per stylesheet that loads
it (a page loading both the home-hero part and the whole basketball part declares
those few themes twice, with the same values). The shared stylesheet fell to about 140 KB gzip, with
the video and basketball parts at 28 KB and 11 KB.
The rule this brings: a route that renders a package must load its part.
Without it the package's components declare no --ph__* variables and render
unstyled, and nothing fails — no build error, no console error, no axe
violation. Check in the browser: every ph__*-theme class a page uses must have
declarations in that page's stylesheets. Adding a route that composes another
package's components (a video card on a team page, a basketball widget in an
article) means adding that part's import to the route's layout.
The global layer is declared once, on :root
tokens.css.ts calls globalTheme.createScoped(":root", tokens.global) rather
than emitting the global layer at the .ph__global-theme class every component
root carries. At the class, each of the ~265 component roots on the home page
re-declared all ~7,600 global properties, and the browser resolved every one on
every element — most of the 684 ms of style work Lighthouse measured. Declared
once and inherited, style and layout on the home page fell from about 670 ms to
200 ms. The competition layers and the page measure's gutter are declared on
the root for the same reason; a formula leaf resolves where it is declared.
Component stylesheets are precompiled
The ghost packages' build compiles every component stylesheet to plain CSS
(packages/ghost-core/scripts/precompile-styles.mjs); only each package's
init.css stays raw, inside the app's vanilla-extract unit, so the club theme
can register and emit. The consequences for day-to-day work are on
Dependency flow: a component package must
be rebuilt, and next dev restarted, before the app shows a change.
Before changing any of this
Pixel-diff full pages at two widths. The per-element re-declaration used to reset a subtree's inherited globals, so anything that relied on that shows up only on a real page, never in a story.
Caching
This page maps the caching layers; how to set up and test them is in the guide, Caching and revalidation.
Four layers
| Layer | Where | Invalidated by |
|---|---|---|
use cache boundaries |
Each data read names its tags and a cacheLife profile (cacheComponents: true in next.config.ts) |
revalidateTag from /api/revalidate; otherwise the profile's TTL |
| Shared tag manifest | apps/web/use-cache-handler.mjs, wired as cacheHandlers.default on self-hosted deploys |
Shares tag invalidation across replicas through Redis (CACHE_REDIS_URL); entries stay per instance |
| The Cloudflare edge | middleware.ts sets a Cache-Tag header per page (lib/cache-tags.ts) |
/api/revalidate purges by tag (lib/cloudflare-purge.ts, when CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID are set) |
| The browser | The router's stale time per profile; live routes' Cache-Control |
Navigation; the engine's max-age and ETag on live polls |
The profiles
Contentful reads use CACHE_PROFILE from @v4/content-app/cache:
editorialDetail (articles and content pages), editorialListing (home,
listings, rails — a short stale time so a mass eviction serves stale and rebuilds
once), slowVocab (categories and tags) and chrome (navigation and settings).
Detail pages carry their slug tag only, listings the coarse content:<type>
tag, and every page the chrome tags.
Competition Engine reads use their own classes (CE_LIFE: structural,
computed, games, gameDetail, played) and ce:* tags, through
cachedEngineGet. Live game data is never cached on the server — see
Live data.
What triggers an invalidation
POST /api/revalidate (secret header x-revalidate-secret,
CONTENTFUL_WEBHOOK_SECRET) is called by:
- Contentful's publish and unpublish webhook —
tagsForPublishinapps/web/src/lib/revalidation.tsmaps the entry to its coarse tag, its slug tag for detail surfaces, and the chrome tags for navigation and settings. Dictionary labels expire throughcontent:dictionaryKey. - The Competition Engine's ingester, after each run —
{ "source": "competition-engine", … }, mapped bytagsForEngineRun.
The same tags are then purged at the edge. The edge header is derived from the path, so it carries coarse, slug and chrome tags only; a publish of an embedded entry (a block, an image) purges every edge-cached surface of its kind instead. Article and content detail pages bypass the edge cache, so the purge matters for the listings and the home page.
Two constraints worth knowing
- Under
cacheComponents, an uncached read makes the whole render request-time. A Nextfetchhint is not a cache; that is why engine reads go through ause cacheboundary and why the basketball routes use the delivery-only Contentful client (a draft-mode check is itself request-time). - Segment config is refused. No route may export
runtimeordynamic; a route that must run per request reads its request (the live and round routes) or sits behind a Suspense boundary.