Pagination and filtering

How the news and video listings page and filter through path-based addresses: page numbers, the filter grammar, seasons and teams.

2026-11-07

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) returns notFound() — so /news/page/999 is 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.

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

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/1 listing 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 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 (listingTeamsFromFragments in @v4/model, read by GetListingTeams through the ListingTeamFields fragment, listingTeamsLoader, cached on content: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 plus associatedTeams: { slug: $team }), and GetArticleCards itself 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 reason GetCurrentSeason is its own query). lib/paging.test.ts holds 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, the accessibleLabel the 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's filters).
  • 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/1 redirect permanently; an unknown key, a repeated key or a junk page is not found. videoCategoryHref builds 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's CategoryFields fragment, transformed by categoryFromFragment); an unknown slug is notFound(). 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 filters contentfulMetadata: { 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:

  1. Add an entry to LISTING_FILTERS in lib/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.
  2. Add the key to LISTING_FILTER_KEYS at its place in the canonical order, and decide which listings take it (CATEGORY_TAG_FILTER_KEYS is the category/tag set).
  3. Map its values onto card-query variables in listingFilterVariables, add them to GetArticleCards (and to GetTeamArticleCards, which the paging test holds in step) and run pnpm --filter @v4/content-app generate. A key over a field a migration is adding gets a twin query instead, as team did, so the surfaces that share GetArticleCards never depend on the migration.
  4. 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.