@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.
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.jsonregistry 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
-addressroute 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, thenformat, thenteam(LISTING_FILTER_KEYS) — and/or a trailingpage/<n>:/news/category/interviews/season/2025-26/team/real-madrid/page/2. A category or tag listing takesseasonandteamonly (CATEGORY_TAG_FILTER_KEYS), so a/format/…address there is not found. /newsitself takesnews/season/[...season]rather than a catch-all: the season is always in a filtered address and always first, so every filtered/newsview starts there.news/page/[page]stays for the bare listing's pages.- A
-legacy-filteredroute 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/1and 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).
News Carousel card counts
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.