App and rendering

How the Next.js app renders pages: route factories, the data flow from each source, theme emission and caching.

2026-11-07

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 bought domains.
  • Theme emission — the club theme is the one emitter of the --ph__* layer (PH_THEME_EMISSION=club, set in next.config.ts) and is emitted in parts: src/theme/tokens.css.ts on every page (the global layer on :root, plus the ghost-core and ghost-content themes), tokens-home-hero.css.ts and competition-themes.css.ts from the locale layout, tokens-video.css.ts from the /video and data-area layouts, tokens-basketball.css.ts from 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 in src/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.ts redirects an address with no locale segment to the default locale's (/news → /en/news), answers 404 for the basketball areas while BASKETBALL_ROUTES=false, and sets the Cloudflare Cache-Tag header per page.
  • The root layout — app/[locale]/layout.tsx imports 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, ScrollManager and React's ViewTransition (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, and deliveryContentClient, 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.ts and sitemaps.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. domains in club.config.ts drives which shims compose writes; a domain not listed has no shims, no route package in the graph and no types in the schema. This site lists content, video and basketball.
  • The wiring is auditable. The generator is drift-gated: pnpm compose -- --check runs in CI (and in pnpm verify before 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
  1. Fragments — @v4/model owns 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 vendored schema.graphql by codegen, which refuses guesses.
  2. Page queries — the page-level query lives in the app (or the route-factory package), composed by spreading @v4/model fragments. 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 explicit limit.
  3. Transforms — @v4/model transforms turn the Contentful response into the normalised shapes components expect. Dictionary copy is resolved on the server with getDictionary(locale) from @v4/model/dictionary — a cached read of the live labels, tagged content:dictionaryKey, so a published label reaches the site without a deploy.
  4. 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.
  5. 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 — tagsForPublish in apps/web/src/lib/revalidation.ts maps the entry to its coarse tag, its slug tag for detail surfaces, and the chrome tags for navigation and settings. Dictionary labels expire through content:dictionaryKey.
  • The Competition Engine's ingester, after each run — { "source": "competition-engine", … }, mapped by tagsForEngineRun.

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 Next fetch hint is not a cache; that is why engine reads go through a use cache boundary 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 runtime or dynamic; a route that must run per request reads its request (the live and round routes) or sits behind a Suspense boundary.