@v4/content-app

The content section's route package: home, news, article and editor-composed pages as route factories, with their queries and cache matrix.

2026-11-07

Overview

@v4/content-app is the CONTENT domain's route package — the page bodies for news and editor-composed pages, shipped as a raw-source workspace package that the composing app mounts through generated shims. It is composable-clubs step 2: the domain owns its routes, its page queries and its codegen, and the app that buys the domain wires them in one place.

Every route is a factory, not a page. The package exports createHomeRoute, createNewsListingRoute, createNewsCategoryRoute, createNewsTagRoute, createNewsListingBySlugRoute, createNewsPathRoute, createNewsArticleRoute and createContentPageRoute; each returns a { Page, generateMetadata } object (createNewsPathRoute adds generateStaticParams). The composing app's generated shim instantiates the factory once with what its deps name in routes.json and statically re-exports the result — this is the shim for news/[...path]:

const route = createNewsPathRoute({ client, buildClient, locale });
export default route.Page;
export const generateMetadata = route.generateMetadata;
export const generateStaticParams = route.generateStaticParams;

That indirection is what makes the domain a bolt-on: the app never writes a page body, only a shim naming the factory, and the route's URL contract (the 16 segments in routes.json, from the root to news/[...path] and the catch-all [...path]) is declared once for the compose-club generator to read. Why the platform routes through factories and shims is on Routes and factories.

createNewsPathRoute (src/news-path/) owns everything under /news/ that is not a fixed route: an article (/news/a-match-report) or a sectioned news listing (/news/eurocup, /news/euroleague/category/interviews). It tries the leading segments as a listing first and only then as an article, delegating to createNewsListingBySlugRoute or createNewsArticleRoute; a competition's news with no editor listing is a generated listing (lib/generated-listing.tsx). See Routes.

Every route under news with params names "boundary": "news" in routes.json, so compose-club generates ONE Suspense layout, apps/web/src/app/[locale]/news/layout.tsx, for all of them: a navigation within News (a filter, a season, a page, an article) keeps the current page on screen until the next is ready, instead of the loading box.

Raw-source package

@v4/content-app is consumed as source, not as a built artefact — the app lists it in transpilePackages and imports its subpaths directly. Every routes.json entry must name a subpath in the exports map (compose-club checks it). The map has 28 keys — . and 27 subpaths:

Subpath What it is
. The route factories, configureImages / pictureSlot / PICTURE_REQUESTS, configureMetadata, the listing address builders, CONTENT_CACHE / CACHE_PROFILE, and the loaders other packages share (below)
./home, ./news-listing, ./news-category, ./news-tag, ./news-listing-by-slug, ./news-path, ./news-article, ./content-page One per route factory. ./news-article is no longer named by routes.json: createNewsPathRoute dispatches to it
./metadata The shared page-metadata contract and the structured-data builders, imported by the video and basketball routes too — see the SEO & metadata guide
./cache The cache matrix (src/lib/cache.ts)
./images configureImages and the slot helpers, without the route barrel (apps/web/src/lib/images.ts)
./listing-address The path-based listing address (lib/listing-filters.ts), shared with the video category page
./listing-seasons The listings' season list and the chrome's current season
./site-sections The sections snapshot (club/0035) every route reads once per render pass
./placeholder-page PlaceholderPageView, the temporary page at a final address (the data areas, a competition's video home)
./page-competition The competition a page belongs to when its address does not say so, so it wears that competition's theme
./chrome-marks The marks the app keeps on <html> for its chrome (no directive, so server and client import them alike)
./team-cards A team's or player's newest articles as news cards (club/0037), for the basketball pages
./wsc-rail One WSC row for every host: the CMS block and the rows a template builds in (a team's Stories, a player's Moments)
./share The share tray's buttons (article header and foot, the video detail page)
./read-time The article header's "X minute read / video" line
./player The provider players' site settings, configured once by the app
./visitor-country The visitor's country for the sponsor geo rules (footer, basketball header sponsors)
./download The same-site address a Download Block row links to (served by apps/web/src/app/api/download/[assetId])
./article-header-widgets configureArticleHeaderWidgets: the app registers @v4/basketball-app's article header widgets — this package cannot depend on basketball-app, which depends on it
./home-hero-widgets configureHomeHeroWidgets: the same seam for the home hero's three areas
./home-fixture-strip configureHomeFixtureStrip: the same seam for the fixture strip band above the home hero (the club's basketball strip, registered by the app)

@v4/video-app and @v4/basketball-app import many of the non-route subpaths — metadata, site-sections, placeholder-page, wsc-rail, team-cards, page-competition, listing-address. Besides the factories, the . barrel exports NEWS_PAGE_SIZE, NEWS_BLOCK_CARD_LIMITS, the listing address helpers (LISTING_FILTER_KEYS, LISTING_FILTERS, listingHref, listingRootOf, newsListingHref, parseFilterSegments, resolveListingAddress, seasonedListingHref and their types), chromeSeasonLoader, siteSectionsLoader, playerArticleCardsLoader, teamArticleCardsLoader and PlaceholderPageView.

The package takes next, react and react-dom as peer dependencies and depends on @v4/ghost-content and @v4/ghost-core for the page components, @v4/model for the client, transforms, fragments and dictionary, @v4/image (the pure provider core) for resolving focal image crops at the render site, @v4/tokens for icons, @v4/analytics for each page's PageAnalytics, and @v4/wsc for the WSC rows. configureImages is the hook the composing app calls (from apps/web/src/lib/images.ts) to wire the per-client image resolver, and configureMetadata is called from apps/web/src/lib/metadata.ts with the site name, base address, crawl switch and the client the Default Share Image is read through.

Owns its queries and codegen

Page-level GraphQL queries live in this package (src/graphql/*.graphql), composed by spreading @v4/model's curated fragments — the house rule that an app never writes raw field selections for a model-owned surface. The package runs its own graphql-codegen against the vendored schema.graphql:

pnpm --filter @v4/content-app generate   # graphql-codegen → src/__generated__
pnpm --filter @v4/content-app drift       # generate + git diff --exit-code (the pre-push check)
pnpm --filter @v4/content-app check-addresses -- --env=develop   # name the pages no address can reach

check-addresses (scripts/check-addresses.mts, read-only) names every clash the shared address space settles silently: an article whose slug a one-segment news listing uses, a listing whose address another already took, a competition or category slug that is one of news's fixed segments or a site route, a team or game slug that is a competition's. It exits 1 when it finds any.

The documents glob is deliberately narrowed to the content domain — src/**, the content fragments, and core's LinkFields only (core is always composed, so content fragments may spread it) — so no other domain's fragment ASTs land in this package's runtime gql.ts. fragmentMasking is off and enums are emitted as types.

The three formats of a news article

createNewsArticleRoute's Page dispatches on the article's authored format — News, Video or Gallery — onto three real base pages. It uses React's cache() so generateMetadata and Page share ONE Contentful request per render pass; the un-cached original fetched twice. A video article leads with its poster and a play button; selecting it swaps the provider's own player in where the poster sat (the Mux video first, else YouTube — provider-video.tsx). A gallery article leads its body with the real EmbedGallery.

Video Items in the body

A Video Item in an Embed Block or an article's Body Copy plays the same way: EmbedVideoPlayer shows the still with its play button, title, and the running time beside the Sub-title (with no Sub-title, the title alone), and swaps the player in on the visitor's click. Both players take their address from one shared module, provider-player.ts. Not @v4/media's MediaPlayer: packages/media belongs to the VIDEO domain, and this package, the always-composed CONTENT domain, does not depend on it.

Playing on the first tap in WebKit

In WebKit (Safari, and every browser on an iPhone or iPad) both players, the Video Item and the video article's header, play on the first tap too. WebKit starts a player with sound only from a tap that reaches the player's own page, and a frame the tap mounts loads after the tap is over, so it used to stop on the provider's own play button and wait for a second tap. A message the page posts during the tap carries the tap with it. So there useStandbyPlayer loads the player as the video nears the screen (within a screen's height), hidden under the still or poster and inert, and the tap posts the provider's play message to it: playVideo through YouTube's IFrame API, play through the hosted Mux player's Player.js. That same frame then takes the still's place. Every other browser passes the click on to the frame it loads (allow="autoplay"), so it keeps loading nothing until the click. A player not ready when the tap comes is told again once it is, by which time WebKit waits for its own play button, as before.

Leaving the page stops the player

Leaving the page stops a Video Item's player. An in-site navigation hides the page it leaves instead of unmounting it: Cache Components keeps up to three pages in <Activity mode="hidden">, and a frame under display: none plays on, sound and all. So both click-to-play players, the Video Item embed (EmbedVideoPlayer) and the video article's header (ProviderVideo), stop their frame in a layout-effect cleanup (useProviderPlayback), which React runs in the commit that hides the page, and the still is back for a visitor who returns. Starting one also stops any other that is playing (claimPlayback in now-playing.ts), so only one video on a page plays at a time. stopProviderFrame takes the frame out of the document and puts it back blank: setting a new src would add a history entry, and navigating the frame in place would let it reload the player when Back moves the hidden page. A stopped standby player is replaced by a fresh frame, which loads only once the video is near the screen again, so a page Next has hidden loads nothing.

Embeds keep their height on Back

An X post and a Script Embed build their content in an effect and empty it in the cleanup, which is what stops their media while Next keeps the page hidden. Without more, Back to an article came back shorter above the reader, and the saved offset showed the content further down. So when a Back or Forward shows the page, each keeps the height it last had there while it builds again (useKeptHeight, lib/kept-height.ts): from the commit that shows the page until its content is as tall again, and only in a column of the width it was measured at. A navigation of the reader's own starts at the top and keeps nothing. One that has not come back after ten seconds gives the room up once it is below the screen, or the reader is at the top of the page, where nothing above it moves. A post X reports unavailable is forgotten, since its row leaves itself out. One gap remains: on a page Next re-creates (it keeps the last three, so a Back to a page left three or more navigations ago), an X post starts pending, and its link sits beside the kept room, so the Back lands a line lower per such post above the reader.

The content cache matrix

Caching lives in ONE reviewed place — src/lib/cache.ts — so tags and TTLs are not scattered literals across the factories. Every query names its surface in CONTENT_CACHE, and tags follow content:{contentType}, with slug-level precision on detail surfaces (content:articlePage:{slug}) matching what the /api/revalidate webhook emits from a publish payload.

Surface Profile (stale/reval/expire s) Tags
articleDetail editorialDetail 60/300/3600 content:articlePage:{slug} (slug tag ONLY)
articleCards editorialListing 30/120/1800 content:articlePage
homePage editorialListing 30/120/1800 content:homePage, content:articlePage
newsListing editorialListing 30/120/1800 content:newsListingPage, content:articlePage
currentSeason editorialListing 30/120/1800 content:siteSettings — read by every listing, and by the article, home and content pages for their listing links
chromeSeason chrome 60/300/86400 content:siteSettings — the same Current Season, read by the app's site chrome and 404 page (chromeSeasonLoader): both are in every page's prerendered shell, so it takes the chrome's life
categories slowVocab 300/3600/86400 content:category
contentPageDetail editorialDetail 60/300/3600 content:contentPage:{path} (path tag ONLY)
recentArticleSlugs editorialListing 30/120/1800 content:articlePage — the newest articles' slugs, read at build for the prerendered article pages
nextArticle editorialListing 30/120/1800 content:articlePage — the header's "next article": listing-shaped, because it depends on every other article's date
siteSections chrome 60/300/86400 content:competition, content:siteSettings, content:contentPage, content:newsListingPage — the sections snapshot (club/0035)
teams slowVocab 300/3600/86400 content:team — the listings' team filter choices
seasonSponsors chrome 60/300/86400 content:season, content:seasonSponsor — a season's "Sponsored by" placements (club/0063)
players slowVocab 300/3600/86400 content:player — the players' editorial twins (wiring plan P3)
richTextEntryLinks editorialDetail 60/300/3600 content:entry:{id} for EVERY requested target — a draft target is absent from the result, so publishing it must expire the lookup

Three design decisions are load-bearing and recorded in the file. Each surface names a CACHE_PROFILE — a cacheLife {stale, revalidate, expire} timespan applied by the app's use cache boundary; stale is the serve-stale-while-revalidating window (a mass eviction serves stale + rebuilds once, never a blocking herd), and revalidate/expire are the self-healing floor — the webhook is the freshness mechanism. Detail surfaces carry the slug/path tag only, never the coarse tag: with both, one article publish expired every article's cached page; the accepted trade is that a page's related-content rail refreshes within its revalidate window rather than instantly. And the descriptors (CONTENT_CACHE, CACHE_PROFILE) are exported from the barrel so the app reuses them for its chrome/sitemap queries. How the read and invalidation paths fit together, and how to add a surface, is in the Caching and revalidation guide.

Where to look next

  • Routes — the routes.json registry and every factory.
  • Sibling packages: @v4/video-app — the first bolt-on route package, built the same way over @v4/ghost-video — and @v4/basketball-app, this club's data areas.

Routes

routes.json is the CONTENT domain's route registry — the compose-club generator reads it to emit the composing app's shims. Each entry names the URL segment, the subpath in the package's exports map, the factory to call, the route params, and the deps the shim must supply. Every subpath must exist in exports, and a segment's [params] must match the factory's documented invariant.

Every listing view is a path, never a query string, so every view is a static, cacheable route (lib/listing-filters.ts, user rulings of 6, 9 and 15 Oct 2026):

  • An -address route reuses its base factory and adds ONE required catch-all, [...address], straight after the slug. It holds key/value pairs in one fixed order — season, then format, then team (LISTING_FILTER_KEYS) — and/or a trailing page/<n>: /news/category/interviews/season/2025-26/team/real-madrid/page/2. A category or tag listing takes season and team only (CATEGORY_TAG_FILTER_KEYS), so a /format/… address there is not found.
  • /news itself takes news/season/[...season] rather than a catch-all: the season is always in a filtered address and always first, so every filtered /news view starts there. news/page/[page] stays for the bare listing's pages.
  • A -legacy-filtered route is an old …/filter/<pairs> address. It only redirects, permanently, to the new shape, so shared and indexed links still land.
  • Pairs in another order, page/1 and a view with no season (the current season is added) redirect to the one canonical address; unknown keys or values are not found.

Every route under news with params names "boundary": "news": their Suspense boundary is one generated layout, news/layout.tsx, so a navigation within News keeps the current page on screen until the next is ready.

The registry

The 16 routes in packages/content-app/routes.json:

id segment factory params deps boundary
home `` (root) createHomeRoute — client, locale —
news-listing news createNewsListingRoute — client, locale —
news-listing-paged news/page/[page] createNewsListingRoute page client, locale news
news-listing-season news/season/[...season] createNewsListingRoute ...season client, locale news
news-listing-legacy-filtered news/filter/[...filters] createNewsListingRoute ...filters client, locale news
news-category news/category/[category] createNewsCategoryRoute category client, locale news
news-category-address news/category/[category]/[...address] createNewsCategoryRoute category, ...address client, locale news
news-category-legacy-filtered news/category/[category]/filter/[...filters] createNewsCategoryRoute category, ...filters client, locale news
news-tag news/tag/[tag] createNewsTagRoute tag client, locale news
news-tag-address news/tag/[tag]/[...address] createNewsTagRoute tag, ...address client, locale news
news-tag-legacy-filtered news/tag/[tag]/filter/[...filters] createNewsTagRoute tag, ...filters client, locale news
news-listing-by-slug news/listing/[newsListing] createNewsListingBySlugRoute newsListing client, locale news
news-listing-by-slug-address news/listing/[newsListing]/[...address] createNewsListingBySlugRoute newsListing, ...address client, locale news
news-listing-by-slug-legacy-filtered news/listing/[newsListing]/filter/[...filters] createNewsListingBySlugRoute newsListing, ...filters client, locale news
news-path news/[...path] createNewsPathRoute ...path client, buildClient, locale news
content-page [...path] createContentPageRoute ...path client, locale —

news-path also sets "staticParams": true, so its shim re-exports generateStaticParams.

Every factory takes a client (the club's @v4/model client) and a locale (the club's default DictionaryLocale, driving the dictionary and date formatting) and returns a { Page, generateMetadata } route object. createNewsPathRoute also takes an optional buildClient: the delivery-only client for its build-time slug list, because client consults draft mode when a preview token is configured and Next refuses draftMode() at build (it defaults to client).

createNewsPathRoute

Everything under /news/ that is not a fixed route (page, season, category, tag, listing, filter): an article or a sectioned news listing (club/0035).

/news/a-match-report                                  the article a-match-report
/news/eurocup                                         EuroCup's listing (an editor's, else generated)
/news/euroleague/category/interviews                  EuroLeague's Interviews listing
/news/euroleague/category/interviews/season/2025-26   …filtered

A listing wins. Articles and listings share the /news/<x> addresses, so the leading segments are tried as a listing's address first — three, then two, then one (newsListingForSegments) — and only a single segment no listing claims is an article, handed to createNewsArticleRoute. A listing goes to createNewsListingBySlugRoute. A competition's news needs no editor: with no listing set up at /news/eurocup (or /news/eurocup/category/interviews) the address is a generated listing (lib/generated-listing.tsx). An editor's listing wins wherever it names at least as much of the address. A category-only listing (/news/category/interviews) is served by the fixed news/category/[category] route, which hands over to it.

An article whose slug a listing or competition already uses is therefore unreachable at that address. pnpm --filter @v4/content-app check-addresses -- --env=develop names every such clash (and the other ways the shared address space hides a page) and exits 1 when it finds one.

generateStaticParams prerenders the newest 100 articles at build (RECENT_ARTICLES_PRERENDERED, the recentArticleSlugs cache surface). An article the build did not list renders on every request and goes out private, no-store until the next deploy.

createNewsArticleRoute

The news-article page, no longer mounted by a shim of its own: createNewsPathRoute dispatches to it. Its Page dispatches on the article's authored format — News, Video or Gallery — onto the three real base pages, sharing one Contentful request between Page and generateMetadata through React's cache(). Reads articleDetail(slug) from the cache matrix (slug-tagged) and transforms the entry with @v4/model's articlePageFromFragment.

createHomeRoute

The home route (root segment). Composes the home page from the club's home-page entry; each of its News Carousels asks for as many cards as its layout shows (see "News Carousel card counts" below).

The hero at the top of the page is HeaderHero, one slide per entry in the Hero Block's Slide Items (up to six), in order. news.label.featured names the carousel, and the slide title is the page's h1. The slides go homePageFromFragment (@v4/model) → homeHeroSlides (home/hero-slides.ts) → HomeView, where every button goes through linkCta:

  • an article slide shows its image and title, its category (linking to the category listing) and date on the line under the title — the month as typed, "Oct 21, 2026", as on the cards — and one button to the article, named by its Format as its card reads it: Read (news.label.readNewsArticle), Watch for a video (news.label.watchVideoArticle) or View for a gallery (news.label.viewGalleryArticle);
  • a Hero Slide shows its image and title, its Slide Section (Pre Title text) on the line under the title, and a button for its Primary then its Secondary Link, labelled with the link's title. A link with no title shows no button. Nor does a link to a fixture, team or profile (which have no page yet) — unless it also has an External Link, which the button then follows (an internal page beats an external address, and a target with no page falls through to it: Link resolution).

An article with no Slug or Article Title, a Hero Slide with no Title, and a slide entry that cannot be resolved (unpublished or deleted) are left out of the hero, so a slide is never shown half-built.

An article's Summary and Sponsor are not shown on the hero, and the view does not carry them. The model still reads a Hero Slide's Tertiary Link — HomePageFields selects it and homePageFromFragment resolves it — but the hero does not show it: HomeView never passes it on. The hero moves only when a visitor uses its arrows.

createNewsListingRoute

The news index. One factory serves four shims: news (page 1), news/page/[page], news/season/[...season] (every filtered view: the season, then the format and the team, then the page) and the old news/filter/[...filters], which only redirects. The listing entry resolves through siteSettings.defaultNewsLandingPage; the card grid is a separate query, NEWS_PAGE_SIZE (12) per page, newest first. A chosen team selects the GetTeamArticleCards twin query (club/0037).

createNewsCategoryRoute

The category listing: news/category/[category], its news/category/[category]/[...address] form for the season, team and page, and the old /filter/ form, which redirects. No format filter.

createNewsTagRoute

The tag listing: news/tag/[tag], news/tag/[tag]/[...address] and the old /filter/ form. No format filter.

createNewsListingBySlugRoute

A SECONDARY news listing — any NewsListingPage that is not siteSettings.defaultNewsLandingPage (the default is served at /news by createNewsListingRoute, and the link resolver keeps the split). Mounted at news/listing/[newsListing] (with its [...address] and old /filter/ forms), and reached through createNewsPathRoute at a listing's sectioned address (/news/<competition>, /news/<competition>/category/<category>). Renders the listing entity like /news does — its own header, featured article and content blocks — with the article grid filtered to the listing's category, tags and competition.

createContentPageRoute

The generic editor-composed page — every ContentPage entry, routed by its fullPath, with nested pages routing naturally as the catch-all segments join back into the authored path. Its News Carousels ask for as many cards as their layout shows, as on the home page. The route is the catch-all [...path] at the app root; Next's static routes (news, theme-lab, …) win over it, so it serves exactly the paths nothing else claims.

The Header Block's Type picks the header (page-view.tsx: info, image, image-rich and feature have their own; anything else is Standard). When the page's Parent Page is one the site serves — it has a Full Path and is not hidden for this locale — the Standard, Info, Image Rich and Feature headers show a Back button that goes to the parent's Full Path, labelled from the dictionary's navigation.action.backButton; the Image header never shows one. The address is resolved once, in contentPageFromFragment (header.parentPath).

How many articles a News Carousel shows is the BLOCK's decision, not the route's. NEWS_BLOCK_CARD_LIMITS (lib/prepare-blocks.tsx, exported from the package root) holds the counts, by layout (15 Oct 2026: the row's eight is unchanged, and the grid counts were agreed with design):

Layout Articles, at most
A row (Display as Grid off) 8 (carousel)
A grid under a featured card 6 beneath it (gridWithFeatured)
A grid with no featured card 9 (grid)
A Tabbed Carousel tab (always a row) 8 (carousel)

The featured card is the block's Featured Grid Article or, when none is set, the newest matching article, but only when that article is itself marked Is Featured?. The News Carousel lifts only a leading featured card: an Is Featured? article further down is not moved, it stays in the grid in date order, and the grid keeps up to nine. Whether the lead is flagged cannot be known until the query returns, so a grid with no Featured Grid Article asks for nine and, when its lead is lifted, keeps the lead and the six after it.

prepareBlocks hands each route's card loader the number to ask for — limit is a required argument of BlockPrepareContext.loadCards — and keeps no more than the layout shows. The home and content pages' loaders and the listings' blockCardsLoader ask for exactly that many; the listings' blocks used to ask for a listing page of fifteen.