Skip to content

Commit 3a9daed

Browse files
myelinated-wackerowclaudewackerow
committed
fix: shard sitemap.xml per locale
Even served statically, a single sitemap document is ~51MB / 17.5k URLs -- over Google's 50MB and 50k-URL per-file limits -- so crawlers reject it outright. Split it into one shard per locale via generateSitemaps(), each ~2MB / ~700 URLs, comfortably under both limits. Each shard still emits the full hreflang alternates block so cross-language reciprocity is preserved. Next serves the shards at /sitemap/<locale>.xml and emits no index file, so robots.ts now lists every shard for crawler discovery in place of the old /sitemap.xml. A memoized traversal keeps all shards sharing a single content walk. Verified against a full 25-locale build: all shards prerender static (initialRevalidateSeconds: false, no ISR), close with </urlset>, carry ~700 URLs each including /wallets/find-wallet/, and the largest is ~2MB. Part 2 of #18014. Note: this removes the root /sitemap.xml; whatever is registered in Google Search Console should be updated to the shard URLs (robots.txt covers auto-discovery regardless). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: wackerow <54227730+wackerow@users.noreply.github.com>
1 parent 3f9f090 commit 3a9daed

2 files changed

Lines changed: 41 additions & 21 deletions

File tree

app/robots.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import type { MetadataRoute } from "next"
22

3-
import { IS_PRODUCTION_DEPLOY, SITE_URL } from "@/lib/constants"
3+
import { IS_PRODUCTION_DEPLOY, LOCALES_CODES, SITE_URL } from "@/lib/constants"
4+
5+
// The sitemap is sharded per locale via generateSitemaps() (app/sitemap.ts).
6+
// Next serves each shard at /sitemap/<locale>.xml and emits no index file, so
7+
// every shard is listed here for crawler discovery.
8+
const sitemapShards = LOCALES_CODES.map(
9+
(locale) => `${SITE_URL}/sitemap/${locale}.xml`
10+
)
411

512
export default function robots(): MetadataRoute.Robots {
613
if (!IS_PRODUCTION_DEPLOY) {
@@ -13,7 +20,7 @@ export default function robots(): MetadataRoute.Robots {
1320

1421
return {
1522
rules: [{ userAgent: "*", allow: "/" }],
16-
sitemap: [`${SITE_URL}/sitemap.xml`],
23+
sitemap: sitemapShards,
1724
host: SITE_URL,
1825
}
1926
}

app/sitemap.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { MetadataRoute } from "next"
22

33
import { getFullUrl, toLanguageTag } from "@/lib/utils/url"
44

5-
import { DEFAULT_LOCALE } from "@/lib/constants"
5+
import { DEFAULT_LOCALE, LOCALES_CODES } from "@/lib/constants"
66

77
import { getAllPagesWithTranslations } from "@/lib/i18n/translationRegistry"
88

@@ -13,43 +13,56 @@ import { getAllPagesWithTranslations } from "@/lib/i18n/translationRegistry"
1313
// re-enters that broken path; freshness rides the deploy cadence instead.
1414
export const dynamic = "force-static"
1515

16-
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
17-
const pages = await getAllPagesWithTranslations()
16+
// One shard per locale. A single combined document is ~51MB / 17.5k URLs -- over
17+
// Google's 50MB per-file limit -- so it must be split. Next serves each shard at
18+
// /sitemap/<id>.xml and emits no index file, so app/robots.ts lists them all.
19+
export async function generateSitemaps() {
20+
return LOCALES_CODES.map((locale) => ({ id: locale }))
21+
}
22+
23+
// getAllPagesWithTranslations walks the whole content tree; memoize so the
24+
// per-locale shards share a single traversal instead of repeating it each time.
25+
let pagesPromise: ReturnType<typeof getAllPagesWithTranslations> | null = null
26+
const getPages = () => (pagesPromise ??= getAllPagesWithTranslations())
27+
28+
export default async function sitemap({
29+
id,
30+
}: {
31+
id: Promise<string>
32+
}): Promise<MetadataRoute.Sitemap> {
33+
const locale = await id
34+
const pages = await getPages()
1835

1936
const entries: MetadataRoute.Sitemap = []
2037
const seenUrls = new Set<string>()
2138

2239
for (const { slug, translatedLocales } of pages) {
40+
// This shard only carries URLs for its own locale; the full hreflang
41+
// alternates block is still emitted so each URL cross-references every
42+
// translated version.
43+
if (!translatedLocales.includes(locale)) continue
44+
2345
const normalizedSlug = slug.startsWith("/") ? slug : `/${slug}`
2446
const alternates =
2547
translatedLocales.length > 0
2648
? {
2749
languages: {
2850
"x-default": getFullUrl(DEFAULT_LOCALE, normalizedSlug),
2951
...Object.fromEntries(
30-
translatedLocales.map((locale) => [
31-
toLanguageTag(locale),
32-
getFullUrl(locale, normalizedSlug),
52+
translatedLocales.map((altLocale) => [
53+
toLanguageTag(altLocale),
54+
getFullUrl(altLocale, normalizedSlug),
3355
])
3456
),
3557
},
3658
}
3759
: undefined
3860

39-
for (const locale of translatedLocales) {
40-
const url = getFullUrl(locale, normalizedSlug)
41-
42-
if (seenUrls.has(url)) {
43-
continue
44-
}
45-
46-
seenUrls.add(url)
61+
const url = getFullUrl(locale, normalizedSlug)
62+
if (seenUrls.has(url)) continue
63+
seenUrls.add(url)
4764

48-
entries.push({
49-
url,
50-
alternates,
51-
})
52-
}
65+
entries.push({ url, alternates })
5366
}
5467

5568
return entries

0 commit comments

Comments
 (0)