Pagination and filtering
How the news and video listings page and filter through path-based addresses: page numbers, the filter grammar, seasons and teams.
Path-based pagination
The news listings paginate through paths, not query strings —
/news/page/2, not /news?page=2. Every page number is its own static,
individually-cacheable route. This is a deliberate scale-readiness ruling:
reading searchParams for the page number would force the whole listing to
render dynamically, including the high-traffic page-1 case. Filters follow the
same rule — see the next page.
The URL shape
| Surface | Page 1 | Page N | Filtered, page N |
|---|---|---|---|
| News listing | /news |
/news/page/N |
/news/season/2025-26/format/video/page/N |
| Secondary listing | /news/listing/x |
/news/listing/x/page/N |
/news/listing/x/season/2025-26/format/video/page/N |
| Category | /news/category/x |
/news/category/x/page/N |
/news/category/x/season/2025-26/team/real-madrid/page/N |
| Tag | /news/tag/x |
/news/tag/x/page/N |
/news/tag/x/season/2025-26/team/real-madrid/page/N |
| Video category | /video/category/x |
/video/category/x/page/N |
/video/category/x/season/2025-26/team/real-madrid/page/N |
The table shows the route shape. Live addresses carry the locale segment in
front — /en/news/page/2 (see Localisation).
A news listing always names its season, so a news address without one redirects to the current season (see filtering).
Page 1 is always the address without a page — /news/season/2025-26.
There is deliberately no /…/page/1: it permanently redirects (308) to the
same address without it, so there is one canonical URL per page (good for SEO
and cache hit rates).
The default listing has one address. The listing set as Site Settings →
Default News Landing Page is served at /news; its own
/news/listing/<slug> — in any form — redirects to the /news equivalent.
Page numbers are strict
Only /^[1-9]\d*$/ is a page number. 0, -2, 02, 2abc, 1.5 and an
empty segment are not found — they used to quietly render page 1 (or page 2
for 2abc). parsePageParam (in
packages/content-app/src/lib/paging.ts) returns the page or undefined; the
route answers notFound() for undefined.
One factory serves every route
The same route factory backs a listing's base and every address under it. A
category, tag or secondary listing has ONE catch-all after its slug,
[...address], which holds the filter pairs and/or page/<n>. /news itself
cannot: news/ already has its own catch-all, news/[...path] (route id
news-path, createNewsPathRoute), which serves articles, competition news
(/news/eurocup) and listings by slug. So /news has the fixed routes
news/page/[page] and news/season/[...season] (every filtered /news view
starts with its season), which win over the catch-all. The old /…/filter/[...filters] routes remain, and
only redirect. One shared helper turns the params into a page and filters, or
exits:
// packages/content-app/src/lib/listing-route.ts
const { filters, page } = listingAddressOrExit(base, await params);
// junk → notFound(); non-canonical (page/1, pairs out of order, an old /filter/ address)
// → permanentRedirect(canonical), or the season redirect when no season is named
The route registry (packages/content-app/routes.json) carries an -address
variant of each slugged listing (and news-listing-paged / -season for
/news), plus a -legacy-filtered variant of each, all reusing the base
factory's subpath, and pnpm compose generates the app shims for every segment.
Sizing and bounds
- Page size is 15 cards (
NEWS_PAGE_SIZE; since 9 Oct 2026 — it was 12). - The query fetches a window with
cardQueryVariables, which turns a page into{ limit: pageSize, skip: (page - 1) * pageSize }. totalPages = Math.max(1, Math.ceil(total / NEWS_PAGE_SIZE)), and a request for a page beyond the last (page > totalPages) returnsnotFound()— so/news/page/999is a real 404, not an empty grid. A filtered view with no results is page 1 of 1 and shows the no-results message.- The featured article leads the grid on page 1 of the unfiltered listing only, and is de-duplicated out of the card list there.
Navigating
Every page button is a real link to its canonical address (crawlable, opens in
a new tab), built by listingHref (exported from @v4/content-app), which
always emits the canonical form and keeps the filter:
getPageHref: (page) => listingHref({ base, filters, page }),
// listingHref({ base: "/news", filters: { season: "2025-26", format: "video" }, page: 2 })
// → "/news/season/2025-26/format/video/page/2"
The video category page does the same with videoCategoryHref
(packages/video-app/src/lib/category-address.ts). The video collection and
playlist pages still keep their page in the query string.
The rule for new listing surfaces
A bounded listing surface must not read searchParams for its page (or any
other bounded axis) — that silently makes page 1 dynamic. Add static path routes
instead, following the -address pattern above.
Filtering
Listing filters follow the same principle as pagination: every filtered view is its own static path, never a query string. There are two kinds:
- Which listing — the news listing, a secondary listing, a category or a
tag. Each is its own route family (
/news,/news/listing/x,/news/category/x,/news/tag/x). - What the visitor narrows it to — the season (every listing), the
article format (news listings only) and the team (every listing). These follow the listing's address directly, with no prefix
(ruling of 15 Oct 2026, so news and the video category page share one shape):
/news/season/2025-26/format/video,/news/category/game-recaps/season/2025-26/team/real-madrid.
The filter address
The pairs sit straight after the listing's own address:
| Listing | Route |
|---|---|
| News listing | /news/season/[...season] |
| Secondary listing | /news/listing/[newsListing]/[...address] |
| Category | /news/category/[category]/[...address] |
| Tag | /news/tag/[tag]/[...address] |
/news takes a static season segment rather than a catch-all of its own,
because news/[...path] is already the catch-all under /news/ (articles,
competition news and listings by slug). That works because the
season is always in a news address and always first. The catch-all holds
key/value pairs in one fixed order, then an optional trailing page/<n>:
/news/season/2025-26
/news/season/2025-26/format/video
/news/season/2025-26/format/video/page/2
/news/season/2025-26/format/video/team/real-madrid
/news/category/game-recaps/page/2
The order is season, then format, then team. Not every listing takes every
key: a category or tag listing has no format filter (ruling of 8 Oct 2026),
so its routes accept season and team only (CATEGORY_TAG_FILTER_KEYS) and a
…/format/… address there is not found.
Old addresses and the grammar
The old /filter/ addresses still work. Every
…/filter/[...filters] route remains and permanently redirects to the new
shape (/news/filter/season/2025-26 → /news/season/2025-26), so shared and
indexed links land. One without a season takes the season redirect instead,
straight to the current season.
The grammar lives in packages/content-app/src/lib/listing-filters.ts (pure and
unit-tested):
| Address | Result |
|---|---|
| known keys and values, canonical order | the filtered page |
| pairs in another order | permanent redirect to the canonical order |
…/page/1 |
permanent redirect to the address without it |
an unknown key or value (format/podcast) |
not found |
a key this listing does not take (format on a tag) |
not found |
a malformed season (season/2025, season/2025-27) |
not found |
| a season not on offer (newer than the current one, older than the oldest article) | not found |
a malformed team (team/Real-Madrid, team/real_madrid) |
not found |
a team no Team entry has (team/barcelona), with or without a season |
not found (no season is added first) |
a repeated key (format/video/format/news) |
not found |
an odd segment (format) |
not found |
an old /filter/… address |
permanent redirect to the new shape |
a junk page (page/02, page/0) |
not found |
| a page past the last | not found |
Every link the site writes names the season
Values are exact: format/Video is not found. listingHref({ base, filters, page }) builds every filter option and page link in the canonical form, and
every other link the site writes to a listing names the current season, so
none of them needs the redirect below:
categoryListingHref(slug, season)— a card's category, an article header's;tagListingHref(id, season)— an article's tag chips;newsListingHref(season)— the related rail's Latest News, the 404 page's Latest news button;seasonedListingHref(href, season)— every other link: a Standard Link to a news listing page (/news,/news/listing/<slug>), an address an editor typed (/news/category/<slug>,/news/tag/<id>, read without the spaces round it), and an old, out-of-order or…/page/1listing address (its canonical form). It is applied to the header and footer, header blocks, the news listing's sub-navigation, block calls to action and a block's Show All (the address its route hands it,/news), the home hero's buttons, an article's related-content button and the links in rich text; every other address is kept as resolved.A listing the
news/[...path]catch-all serves — a competition's news (/news/eurocup), a competition's category (/news/eurocup/category/<slug>), an editor's listing named by its own slug — has an article's kind of address, so the address alone cannot say it is a listing. Each of those callers passes the site's sections as a third argument (NewsListingSections: every competition's slug and every listing's address), and the link is read with the catch-all's own rule,newsListingAt(@v4/model), so it is written as a listing's exactly where the route serves one. That covers a competition homepage's Show All, a block's Link to a competition's news, the News each competition's section swaps into the header (sections.competitions[].news), a competition footer's links and the 404 page's useful links. Without the sections such a link is kept as written.
The season in the header, the footer and the 404 page
The header, the footer and the 404 page read the season through
chromeSeasonLoader: Site Settings' Current Season, under the chrome's own
cache life, because the chrome and the root not-found are in every page's
prerendered shell. Only while that field is empty (or unreadable) does it fall
back to the listings' answer, the newest article's season, so the links still
name it; that read carries the content:articlePage tag and a listing's cache
life into every page's shell, so set Current Season. The header's
current-section mark reads a link the chrome wrote with the season as the
listing it leads to, so News stays marked on every news page, while a link to
a format view or another season matches only that view (currentSection); a
competition's seasoned News is read back with the chrome's
sections.newsListings, so EuroCup's stays marked on all of EuroCup's news. The
404 page also writes a bare data area (/games) as the default competition's
list, as the chrome does (defaultAreaHref). A
bare address still arrives from outside the site's own links — a bookmark, a
search result, the sitemap, an absolute address typed into the CMS, a link
inside a Script Embed's own markup (the site renders that markup as written) —
and from a page that could read no season at all; it still reaches the right
listing, through the redirect. One seasoned address keeps a hop too: the
default listing's own /news/listing/<slug>, typed into the CMS, whose route
sends every form of it on to /news (a Standard Link to that listing already
resolves to /news).
The format filter
News listings and secondary listings offer Show All / News / Video /
Gallery. format maps onto the Article's Format field (format, values
News | Video | Gallery). Choosing an option navigates to the canonical
filtered address on the same listing, at page 1, keeping any season;
"Show All" is the address without a format. Paging keeps the filter. Category
and tag listings have none. The labels are dictionary keys:
news.label.allFormats, news.label.news, news.label.video,
news.label.gallery.
The season filter
Every listing shows a season dropdown above the grid (with a calendar
icon). A season runs 1 September to 31 August, in UTC (lib/season.ts) and
is named by its start year: in an address 2025-26, on screen "Season 25/26"
(news.label.season, "Season {season}").
- The choices are the same on every listing: the current season back to the
season of the oldest published article on the site (
GetArticleDateRange), newest first. A season with no articles is still offered. - There is no "all seasons" choice: a listing grid is always narrowed to one
season, and the season is always in the address (ruling of 9 Oct 2026):
/season/<season>, the current season included. An address without one (/news,/news/page/2,/news/category/<slug>,/news/category/<slug>/page/2) redirects — temporarily, because which season is current changes — to the same view with the current season added (listingSeasonOrExit). The featured article leads only page 1 of the current season with nothing else chosen (isDefaultListingView). - That redirect cannot be an HTTP 307: the listing has already started streaming, so the bare address answers 200 with a one-second meta refresh. In the browser, Next's redirect boundary then empties the page — the footer moves up under the header — and, because the meta tag is in the page, loads the seasoned address as a second document. Measured on the real app, every tap on a bare category link loaded two documents, and most showed the footer, which is why every link the site writes to a listing names the season instead. An in-app navigation shows the same empty page for as long as the redirect takes: the footer flashed on every tag chip and Latest News tap.
- The season sets a Publish Date range on
GetArticleCards(publishDate_gte/publishDate_lt), so the page count only counts that season. - Which season is current is set in Site Settings (Current Season,
club/0021), so the 1 September roll-over never waits for a cache to expire.
Left empty, or unreadable, it is the season of the newest published
article, never the clock (
listingSeasonsLoader,lib/listing-seasons.ts). The article, home and content pages read the same answer for their listing links (currentSeasonLoader), and the site chrome and the 404 page read Site Settings' value first (chromeSeasonLoader, above); if it cannot be read at all, the link stays bare and the redirect still takes it to the right listing.
The team filter
Every listing (news, secondary, category and tag) shows a team dropdown beside the season dropdown, with the filter icon:
- The choices are All Teams (
news.label.allTeams), then every Team entry (Item 🏷️ Team,team, created by club/0030) by its Name, lowest Sort Hierarchy first; a team with no Sort Hierarchy comes after the rest, and teams that tie are in name order (listingTeamsFromFragmentsin@v4/model, read byGetListingTeamsthrough theListingTeamFieldsfragment,listingTeamsLoader, cached oncontent:team). With no team set up there is no dropdown, as on the video category page. - The team is the Team's Slug in the address:
/news/season/2025-26/team/real-madrid. The address accepts any slug-shaped value; the route turns one that no Team entry has away as not found (listingTeamOrExit) before it adds a missing season, so a junk team with no season is not sent to a seasoned address first. Pairs out of order are still put in order first (a permanent redirect, then the not found), exactly as for a season that is not on offer. - Choosing a team lists the articles that have it among their Associated
Teams (
articlePage.associatedTeams, a list of up to 5 Team references, club/0037). All Teams is the address without a team and lists every article, those with no team included. Choosing any other filter, or paging, keeps the team; choosing a team returns to page 1 and keeps the others. - A chosen team narrows a twin of the card query,
GetTeamArticleCards(GetArticleCards plusassociatedTeams: { slug: $team }), andGetArticleCardsitself never names the field. Contentful refuses a whole query that names a field the environment does not have yet, and GetArticleCards also serves the home page, content pages' carousels and every listing with no team chosen, so none of them depends on club/0037 having reached the environment (the reasonGetCurrentSeasonis its own query).lib/paging.test.tsholds the two queries in step, field for field. - The trigger shows only the choice ("Real Madrid"), so the dropdown is named
"Team" for assistive technology (
news.label.team, theaccessibleLabelthe video category page's team dropdown also has).
Where the filters sit
Fixed by the rulings of 8 Oct 2026, whatever the listing's Featured Article:
- The format filter is always in Header Controls (news listing and secondary listings), with the Sub-Navigation links.
- The season filter, then the team filter, are above the grid, in the
news list: side by side at the far end of the row on a wide screen, stacked
full width on a phone (
PaginatedList'sfilters). - No dropdown shows a "Filter" label.
The featured article shows only on page 1 of the unfiltered listing — the current season, no format, no team — de-duplicated from the grid there, with no heading above it. Any filter in the address removes it, another season included. With no links and no format filter there is no Header Controls at all.
Choosing a filter
Choosing a filter (format, season, team or competition) is an in-app
navigation to that view's address, and so is paging. The current results stay
on screen until the next are ready, then swap in place; the full-viewport
placeholder that a full load, or arriving from another section, opens on is not
shown again. The same holds for every navigation within News, an
article opened from a listing included: the section has one boundary, above
every listing and article — boundary in the guide
Add a page or route. The video category page does
not do this yet: its filters still show the placeholder.
The video category page
/video/category/<slug> uses the same shape and the same grammar
(parseFilterPairs, through the pure @v4/content-app/listing-address subpath),
with its own keys — season, team, competition, in that order:
/video/category/highlights
/video/category/highlights/page/2
/video/category/highlights/season/2025-26/team/zalgiris-kaunas/competition/euroleague/page/2
- The choices are the video platform's (its ContentData lists), and the
platform filters by each choice's entry id. The address names a choice by
its name's slug (
Žalgiris Kaunas→zalgiris-kaunas), looked up in those lists (packages/video-app/src/lib/category-filters.ts). A slug no choice has is not found. - Unlike news, the default is every season: no pair means "all", and choosing All removes the pair. Choosing any filter returns to page 1.
- Pairs out of order and
page/1redirect permanently; an unknown key, a repeated key or a junk page is not found.videoCategoryHrefbuilds every option and page link (packages/video-app/src/lib/category-address.ts).
Category and tag routes
| Filter | Route | Factory |
|---|---|---|
| Category | /news/category/[category] |
createNewsCategoryRoute |
| Tag | /news/tag/[tag] |
createNewsTagRoute |
- Category resolves the slug to a category entry (
GetCategoryBySlug, spreading@v4/model'sCategoryFieldsfragment, transformed bycategoryFromFragment); an unknown slug isnotFound(). The header is the category's own label and summary, and the card query filters by the category's entry id. - Tag is a Contentful public tag (entry metadata, not a field). The
address segment is the tag's id, verbatim (
/news/tag/initiatives), and the card query filterscontentfulMetadata: { tags: { id_contains_some: $tags } }. The header, meta title and "Explore a wide range of content related to …" line use the tag's name (falling back to the id). The Delivery GraphQL API has no tag query, so the name is read off the first article carrying the tag (GetPublicTagName). The page 404s when no article carries the tag — checked independently of the season, so a real tag with nothing in the season showing gets "no results" — and when the segment cannot be decoded (a malformed%sequence).
News listings and News Carousels (and each Tabbed Carousel tab) filter by the
public tags applied to their own entry, through the same tags variable.
How a filter becomes query variables
Filters are AND: each key narrows the same GetArticleCards query.
cardQueryVariables (in packages/content-app/src/lib/paging.ts) builds the
where-filter, and the important detail is that filters are omitted, never
null:
export const cardQueryVariables = (filters, page, pageSize = NEWS_PAGE_SIZE) => ({
limit: pageSize,
skip: (page - 1) * pageSize,
...(filters.categoryId != null ? { categoryId: filters.categoryId } : {}),
...(filters.tags.length > 0 ? { tags: filters.tags } : {}),
...filters.selected, // { format: "Video", team: "real-madrid", publishDateGte, publishDateLt } — format and team omitted when unset
});
This matters against GraphQL: an unprovided variable removes the
where-field entirely, whereas an explicit null would match articles that have
NO value — a different, wrong result (verified live against develop). The
same goes for tags: an omitted $tags applies no tag filter, but an empty
list ([]) matches nothing.
A chosen team is the exception: listingCardsLoader takes team out of the
variables and sends the rest to GetTeamArticleCards, where $team is
required (see the team filter above). With no team chosen, the request is
GetArticleCards, unchanged.
Adding a filter key
Adding a key needs no new route — the catch-all already carries it:
- Add an entry to
LISTING_FILTERSinlib/listing-filters.ts: a fixed table of address values (format), or a pattern its values must match (season,team). Where the values are content (team), the route also checks the value names a real entry, and answers not found when it does not. - Add the key to
LISTING_FILTER_KEYSat its place in the canonical order, and decide which listings take it (CATEGORY_TAG_FILTER_KEYSis the category/tag set). - Map its values onto card-query variables in
listingFilterVariables, add them toGetArticleCards(and toGetTeamArticleCards, which the paging test holds in step) and runpnpm --filter @v4/content-app generate. A key over a field a migration is adding gets a twin query instead, asteamdid, so the surfaces that share GetArticleCards never depend on the migration. - Add its control to the listing (and its dictionary keys).
The ordering, redirect and not-found rules then apply to it automatically; they
are proven in listing-filters.test.ts, the season maths in season.test.ts.
Multi-value and search are out of scope here
Each key takes exactly one value. Multi-select and free-text search are intentionally not modelled as paths — paths would be unbounded, and a dynamic full-page render would defeat the static-listing ruling. The documented direction for those (per the web-app caching reference) is a client fetch against a tag-cached data endpoint behind a static shell. That endpoint is not present in the current codebase.