Sitemaps
How the sitemap corpus is built from articles, pages, listings and data areas, chunked into an index and children, and cached by tag.
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.
- 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/videowhen the club has thevideodomain composed (clubConfig.domains). - Articles —
/news/{slug}for every published article, each carrying itssys.publishedAtaslastModified— except one set to Exclude from search engines? (where: { excludeFromSearchEngines_not: true }, which keeps entries where the field is unset). - Content pages — the page's
fullPath(the real, possibly NESTED route the app serves at/[...content]), falling back to itsslugonly whenfullPathis absent — the same rule the link resolver applies. Listing a bareslugfor a nested page would emit a 404 URL. WithlastModified. A page set to Exclude from search engines? is left out, by the same filter. - Secondary news listings — every
NewsListingPagethat 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/newsstatic entry above); the split is detected vialinkedFrom.siteSettingsCollection, the same signal the link resolver uses. WithlastModified. - Competition news — every competition's news listing (
/news/euroleague,/news/eurocup, …), fromcompetitionNewsSitemapEntries(base, sections)insitemap-url.ts. Each competition's News exists whether or not an editor set a listing up (it is generated otherwise). - Categories —
/news/category/{slug}for every category (nolastModified). - The basketball data areas — from
@v4/basketball-app/sitemap:teamsSitemapPaths,playersSitemapPaths,coachesSitemapPaths,standingsSitemapPaths,statsSitemapPathsandgamesSitemapPaths. 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 whenclubConfig.domainsincludesbasketballand the routes are on (basketballRoutesEnabled(), theBASKETBALL_ROUTESswitch inapps/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 itsidas a Promise (the async-params change), so it isawaited; theawaitalso 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.