Sitemaps

How the sitemap corpus is built from articles, pages, listings and data areas, chunked into an index and children, and cached by tag.

2026-11-07

Generation

This is the XML sitemap search engines read. The map of the site's pages and addresses for people is the Site map.

The app publishes a sitemap index at /sitemap.xml that points at a set of chunked child sitemaps under /sitemaps/sitemap/[id].xml. The whole thing is generated from Contentful — there is no hand-maintained URL list.

The corpus

sitemapCorpus() in apps/web/src/lib/sitemaps.ts assembles every URL the site owns, in a stable order (static entries first, then dynamic). Every path goes through localePath (@v4/model/core) before the host is put on, so each URL carries the locale segment — https://…/en/news/a-match-report.

  1. Static entries — the home page, every other competition's homepage (competitionHomeHref, the competitions from the site sections, club/0035 — the default competition is the home page itself), the news landing (/news), and /video when the club has the video domain composed (clubConfig.domains).
  2. Articles — /news/{slug} for every published article, each carrying its sys.publishedAt as lastModified — except one set to Exclude from search engines? (where: { excludeFromSearchEngines_not: true }, which keeps entries where the field is unset).
  3. Content pages — the page's fullPath (the real, possibly NESTED route the app serves at /[...content]), falling back to its slug only when fullPath is absent — the same rule the link resolver applies. Listing a bare slug for a nested page would emit a 404 URL. With lastModified. A page set to Exclude from search engines? is left out, by the same filter.
  4. Secondary news listings — every NewsListingPage that is NOT the site default, at the address it is served at: named by what it lists when the sections know it (sections.newsListingPaths), else /news/listing/{slug}. The DEFAULT listing is excluded (it is the /news static entry above); the split is detected via linkedFrom.siteSettingsCollection, the same signal the link resolver uses. With lastModified.
  5. Competition news — every competition's news listing (/news/euroleague, /news/eurocup, …), from competitionNewsSitemapEntries(base, sections) in sitemap-url.ts. Each competition's News exists whether or not an editor set a listing up (it is generated otherwise).
  6. Categories — /news/category/{slug} for every category (no lastModified).
  7. The basketball data areas — from @v4/basketball-app/sitemap: teamsSitemapPaths, playersSitemapPaths, coachesSitemapPaths, standingsSitemapPaths, statsSitemapPaths and gamesSitemapPaths. Broadly, every competition's list pages plus a page per club, player, coach and game of the default competition's current season (each function's docblock says exactly which). They are read from the Competition Engine, and listed only when clubConfig.domains includes basketball and the routes are on (basketballRoutesEnabled(), the BASKETBALL_ROUTES switch in apps/web/src/lib/basketball-routes.ts). A failed engine read drops that area's engine-backed pages (logging the error) rather than failing the sitemap.

sitemaps.ts imports ./competition-engine for its side effect alone: that is what configures the engine client. Only the page shims imported it before, so the sitemap's engine reads threw "not configured" and every club and player silently fell out of the catalogue (23 Oct 2026).

Finally the corpus is de-duplicated by URL, first entry wins. That is how an editor's listing at a competition-news address (listed in step 4 with its own lastModified) beats the generated entry for the same address in step 5.

Each entry is a SitemapEntry — { url, lastModified? } — and every URL is built from clubConfig.seo.baseUrl (trailing slash stripped once), so each environment's canonical host is applied in one place. The URL rules live as pure, unit-tested helpers in sitemap-url.ts (contentPageSitemapEntry for the nested-route rule, newsListingSitemapEntry for the default/secondary split, competitionNewsSitemapEntries), split out of the server-only fetch layer so the URL correctness is directly asserted.

sitemapCorpus() is wrapped in React's cache(), so within a single request the index route, generateSitemaps() and every child chunk resolve through one corpus build; across requests the underlying reads are additionally tag-cached (see the next page).

Not yet in the corpus (recorded, not forgotten): video detail pages — their data comes from the platform API via @v4/media, not Contentful, so they need a separate paged source. The static /video landing IS included when the video domain is composed.

Paging the Contentful reads

Articles, content pages and secondary news listings are fetched in pages of QUERY_PAGE (500) via a small collect() helper that loops skip forward until it has gathered the collection's reported total (or a page comes back empty). This is a request budget against the GraphQL complexity limit — 500 items per query — and is unrelated to how the corpus is later chunked into sitemap files. Categories are fetched once with a flat limit of 200.

The routes

Route File Role
/sitemap.xml app/sitemap.xml/route.ts the sitemap index listing children
/sitemaps/sitemap/[id].xml app/sitemaps/sitemap.ts one child sitemap per chunk id

The index route builds sitemapCount(corpus) <sitemap> entries, each pointing at {baseUrl}/sitemaps/sitemap/{id}.xml. The child route uses Next's generateSitemaps() to enumerate the same ids and returns each chunk's slice.

Next 16 note: the child sitemap() receives its id as a Promise (the async-params change), so it is awaited; the await also tolerates the plain number older majors pass.

robots.txt

apps/web/src/app/robots.ts derives the rules, the sitemap address and the host from club.config.ts (deriveRobots(clubConfig)); crawling is off unless NEXT_PUBLIC_ROBOTS_ALLOW=true. It calls await connection() so the route is rendered per request and never frozen in the shared Redis cache — a once-cached body would otherwise outlive any change to the flag.

Chunking and cache tags are covered on the next page.

Chunking & tags

Why chunk at all

The sitemaps protocol caps a single sitemap file at 50,000 URLs. EuroLeague's archive (articles, teams, players and games across seasons) can exceed that, so the corpus is split into an index plus child sitemaps, each holding at most ITEMS_PER_SITEMAP entries.

ITEMS_PER_SITEMAP is 40,000 — deliberately under the 50k protocol cap. The maths lives in two pure helpers in apps/web/src/lib/sitemaps.ts:

export const sitemapChunk = (corpus, id) =>
  corpus.slice(id * ITEMS_PER_SITEMAP, (id + 1) * ITEMS_PER_SITEMAP)./* → MetadataRoute.Sitemap */;

export const sitemapCount = (corpus) =>
  Math.max(1, Math.ceil(corpus.length / ITEMS_PER_SITEMAP));

sitemapCount never returns 0 — there is always at least one child sitemap, even for an empty corpus — so the index always lists a valid child. The index route and generateSitemaps() both derive their id range from the same sitemapCount(corpus), so the index and the children can never disagree on how many chunks exist.

Note the two page sizes are independent: QUERY_PAGE (500) is how many entries each Contentful request fetches; ITEMS_PER_SITEMAP (40,000) is how many URLs go in each child sitemap file.

Cache tags

Each Contentful read inside sitemapCorpus() is tagged by the content surface it consumes and cached with the slowVocab profile (CACHE_PROFILE.slowVocab from @v4/content-app/cache: stale 300s, revalidate 3600s, expire 86400s):

Corpus read Tags Profile
Articles content:articlePage slowVocab
Content pages content:contentPage slowVocab
News listings content:newsListingPage slowVocab
Categories content:category slowVocab

These are the same coarse content tags the rest of the app uses (see the Caching & revalidation guide), so a publish that expires content:articlePage also invalidates the sitemap's article corpus — the sitemap picks up new and removed URLs on the same webhook that refreshes the listings, self-healing within the hour if a webhook is ever lost.

The basketball data areas' reads go through @v4/competition-engine instead, with its own ce:* tags and CE_LIFE cache classes (see @v4/competition-engine). The data areas also read the sections and teams from Contentful, through the site's normal tagged reads.