Building a Sitemap That Can't Go Stale: A Config-Driven Architecture for Next.js App Router
Every team that ships a marketing site on Next.js eventually writes the same file: sitemap.ts. It loops over some blog posts, hardcodes a few static paths, and gets shipped without much thought. Six months later, someone notices Google has indexed /dashboard/settings, three new landing pages never made it into the sitemap, and nobody can explain why the homepage and a random FAQ page both have priority: 0.8.
This isn't a hypothetical. It's the natural failure mode of treating a sitemap as a one-off script instead of a piece of infrastructure. After running into exactly this problem on a bilingual (English/Arabic), App Router-based Next.js app with a large admin console living in the same codebase as the public marketing site, I want to walk through the architecture that fixed it: colocated route metadata, a build-time drift check, locale-aware hreflang generation, and a human-readable sitemap rendered through XSLT.
None of this is exotic. It's mostly discipline, encoded so that the discipline can't be skipped.
The problem with the "loop over content" sitemap
The default pattern most tutorials show looks like this:
// app/sitemap.ts
import type { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
return [
{ url: 'https://example.com', priority: 1 },
{ url: 'https://example.com/about', priority: 0.5 },
...posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.updatedAt,
priority: 0.6,
})),
]
}
This works fine for a five-page site. It breaks down once you have three categories of pain happening at once:
-
Route sprawl. A real app router tree has dozens of routes across marketing pages, a blog, case studies, service/industry landing pages, and — critically — a private admin console that lives under the same domain and the same
[locale]segment as the public pages. - Locale multiplication. Every public route exists in at least two languages. A sitemap that only emits English URLs is silently telling search engines that half your site doesn't exist.
- Metadata drift. Priority, change frequency, and indexability decisions get made once, in someone's head, and never revisited when new pages are added.
The fix isn't a smarter loop. It's moving sitemap metadata out of a single centralized file and into the same place as the route it describes, then verifying at build time that nothing was left out.
Architecture overview
At a high level, the system has four moving parts:
flowchart LR
A[Route files in app/ directory] --> B[Colocated sitemapConfig metadata]
B --> C[Build-time manifest generator]
C --> D{Drift check}
D -->|New page missing config| E[Build warning / CI failure]
D -->|All routes accounted for| F[routes.manifest.json]
F --> G["/sitemap.xml route handler"]
G --> H[Per-locale URL + hreflang expansion]
H --> I[XML response + XSL stylesheet]
I --> J[Search engines / browsers]
The important design decision here is where the metadata lives. Instead of a central sitemap.ts that has to know about every route in the app, each route file exports a small, typed object describing itself:
// app/[locale]/blog/[slug]/page.tsx
export const sitemapConfig = {
priority: 0.6,
changeFrequency: 'monthly',
} as const
export async function generateStaticParams() {
const posts = await getPublishedPosts()
return posts.map((post) => ({ slug: post.slug }))
}
A static page that never changes gets something simpler:
// app/[locale]/privacy/page.tsx
export const sitemapConfig = {
priority: 0.3,
changeFrequency: 'yearly',
noIndex: true,
} as const
And an admin page — anything under a route group like (admin) — simply doesn't export sitemapConfig at all, because it isn't supposed to be in the sitemap in the first place.
This colocation matters more than it looks. When a developer adds a new marketing page, the sitemap behavior for that page is decided in the same pull request, by the same person, sitting right next to the code that renders it. There's no separate "update the sitemap" step to forget.
The drift check
Colocated metadata alone doesn't solve the problem — it just moves it. You still need something that notices when a page exists on disk but has no sitemapConfig, or when sitemapConfig exists but the route was deleted. That's the job of a small build-time script that walks the app/ directory, matches page files against a manifest, and either updates the manifest or fails loudly.
// scripts/generate-route-manifest.ts
import { globSync } from 'glob'
import path from 'node:path'
type ManifestEntry = {
route: string
hasSitemapConfig: boolean
isDynamic: boolean
isAdmin: boolean
}
function buildManifest(): ManifestEntry[] {
const pageFiles = globSync('src/app/[locale]/**/page.tsx')
return pageFiles.map((file) => {
const route = toRoutePath(file)
return {
route,
hasSitemapConfig: fileExportsSitemapConfig(file),
isDynamic: route.includes('['),
isAdmin: file.includes('(admin)'),
}
})
}
function reportDrift(manifest: ManifestEntry[]) {
const missing = manifest.filter(
(entry) => !entry.isAdmin && !entry.hasSitemapConfig
)
if (missing.length > 0) {
console.warn(
`⚠ ${missing.length} public route(s) have no sitemapConfig:`,
missing.map((m) => m.route)
)
}
}
The output of this script is a static JSON manifest checked into the repo (or generated in CI). Crucially, it's not a runtime dependency — it runs once at build time, and the sitemap route handler simply reads the result. This keeps the actual /sitemap.xml request fast; it's not walking the filesystem on every crawl.
The warning-instead-of-hard-failure choice matters too. A hard failure on missing metadata sounds appealing until a designer adds a one-off internal tools page and the entire build breaks over a sitemap entry nobody cares about. A warning that shows up in build logs and CI output gets seen without blocking shipping — and if a team wants stricter enforcement, turning it into a failure for specifically the (public) and default route groups is a one-line change.
Locale-aware sitemap generation
This is the part most sitemap tutorials skip entirely, and it's the part that actually causes indexing problems in production. If your app uses [locale] segment routing (the standard pattern with next-intl or similar libraries), every URL in your sitemap needs to exist once per locale, and each locale variant needs to point at its siblings via hreflang alternates.
graph TD
subgraph "One logical page"
EN["/en/blog/scaling-postgres"]
AR["/ar/blog/scaling-postgres"]
end
EN -- "hreflang=ar" --> AR
AR -- "hreflang=en" --> EN
EN -- "hreflang=x-default" --> EN
Get this wrong in either direction and you get one of two failure modes:
- Only the default locale is in the sitemap. Search engines discover the other locale through links eventually, but crawl budget and indexing lag suffer, and international search visibility is worse than it should be.
-
Locales are listed with no hreflang alternates. Google may treat
/en/blog/xand/ar/blog/xas unrelated pages competing for the same query, instead of understanding they're translations of one another.
The sitemap protocol handles this through <xhtml:link rel="alternate" hreflang="..."> entries inside each <url> block. Expanding the manifest into locale variants looks roughly like this:
const locales = ['en', 'ar'] as const
function expandForLocales(entry: ManifestEntry, baseUrl: string) {
return locales.map((locale) => ({
loc: `${baseUrl}/${locale}${entry.route}`,
alternates: locales
.filter((l) => l !== locale)
.map((altLocale) => ({
hreflang: altLocale,
href: `${baseUrl}/${altLocale}${entry.route}`,
}))
.concat({ hreflang: 'x-default', href: `${baseUrl}/en${entry.route}` }),
priority: entry.priority,
changeFrequency: entry.changeFrequency,
}))
}
For dynamic routes (blog posts, service pages, case studies), this expansion happens against each item returned from generateStaticParams, not against the route pattern itself — so a blog post that's only published in English so far doesn't get a broken Arabic sitemap entry pointing at a 404.
Why a route handler instead of the built-in sitemap.ts
Next.js ships a built-in Metadata Files API for sitemaps, including support for splitting large sitemaps via generateSitemaps. For a lot of sites, that's the right tool and you should reach for it first. The custom route handler approach earns its complexity in specific circumstances:
| Concern |
app/sitemap.ts (built-in) |
Custom route handler |
|---|---|---|
| Simple, single-locale site | Ideal | Overkill |
| Multi-locale hreflang alternates | Possible, but you build the XML shape yourself either way | Same effort, more control over structure |
| Human-readable output via XSL | Not supported natively | Full control — serve a linked .xsl stylesheet |
| Splitting across multiple sitemap files (10,000+ URL limit) | Built-in via generateSitemaps
|
Manual, but straightforward with a sitemap index file |
| Excluding whole route groups (e.g. admin) automatically | Manual filtering in the function body | Manual filtering, but colocated with route metadata |
| Caching / revalidation control | Framework-managed | Full control (route handler revalidate export, or on-demand) |
The deciding factor, in this case, was the combination of hreflang complexity and wanting a sitemap that's actually pleasant to open in a browser. Both are achievable with the built-in API, but a route handler makes the XML structure and the accompanying stylesheet explicit rather than inferred.
// app/sitemap.xml/route.ts
export const revalidate = 3600 // seconds
export async function GET() {
const manifest = await loadRouteManifest()
const entries = manifest.flatMap((entry) =>
expandForLocales(entry, process.env.SITE_URL!)
)
const xml = renderSitemapXml(entries, {
stylesheet: '/sitemap.xsl',
})
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=0, s-maxage=3600',
},
})
}
The XSL stylesheet: a small, underrated detail
Anyone who has opened a raw sitemap.xml in a browser knows the experience: an unstyled wall of angle brackets that Chrome renders as a barely-readable tree. Browsers support attaching an XSLT stylesheet to XML documents via a processing instruction, which turns that same file into an actual HTML table — clickable links, priority and last-modified columns, locale badges — with zero JavaScript and zero extra requests beyond the stylesheet itself.
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://example.com/en/blog/scaling-postgres</loc>
<xhtml:link rel="alternate" hreflang="ar" href="https://example.com/ar/blog/scaling-postgres"/>
<lastmod>2026-06-12</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>
The stylesheet itself is served from its own route handler (app/sitemap.xsl/route.ts) as application/xslt+xml, and browsers apply it automatically when they load the XML document directly. Search engine crawlers ignore the stylesheet entirely and parse the raw XML — it's purely a debugging and QA convenience for humans, but it's the kind of detail that makes reviewing sitemap changes in a pull request preview actually usable instead of dreadful. See MDN's XSLT documentation for the underlying mechanism.
Keeping the admin console out of the index
The other half of this problem is exclusion, not inclusion. A large Next.js app that hosts both a marketing site and an authenticated admin console under the same domain has to be deliberate about which routes are even eligible to appear in a sitemap or be indexed at all. In practice this means three overlapping layers of defense, not one:
-
Route grouping. Admin routes live under a
(admin)route group, which never exports asitemapConfig, so the manifest generator excludes them structurally rather than by convention. -
generateMetadatasetsrobots: { index: false }on every admin page's metadata, independent of the sitemap. -
A
robots.txtdisallow rule for the admin path prefix, as defense in depth in case a page is ever linked externally before its metadata ships.
// app/[locale]/(admin)/layout.tsx
export async function generateMetadata() {
return {
robots: { index: false, follow: false },
}
}
# app/robots.txt
User-agent: *
Disallow: /*/dashboard/
Sitemap: https://example.com/sitemap.xml
Relying on just one of these is how /dashboard/settings ends up in Google's index. noindex meta tags only work if the crawler is allowed to fetch the page in the first place, and robots.txt disallow rules alone don't remove a URL that's already indexed — they just stop future crawling of it. Google's own guidance on robots meta tags is worth reading closely on this exact interaction; it's more subtle than "add one directive and move on."
Common mistakes
- Hardcoding the sitemap in one file that nobody updates when routes change. This is the root cause of almost every stale-sitemap problem; colocated metadata with a drift check is the direct fix.
- Emitting only one locale's URLs. Half your content becomes invisible to search engines in other languages, even though the pages exist and are perfectly crawlable.
-
hreflang without reciprocity. If
/en/xpoints to/ar/xbut/ar/xdoesn't point back, search engines are explicit that they may ignore the annotation entirely. -
Using
noindexand expecting it to remove already-indexed pages instantly. It stops future indexing; removal takes a crawl-and-recrawl cycle, sometimes accelerated by Search Console's removal tool. - Forgetting the 50,000 URL / 50MB per-sitemap limit. Large content sites need a sitemap index file that references multiple child sitemaps, not one gigantic file.
-
No
lastmod, or alastmodthat's just "today" on every build. This actively wastes crawl budget — alastmodthat's always current is worse than nolastmodat all, because it signals constant change where there is none. -
Treating priority as universally meaningful. Google has stated for years that it largely ignores the
priorityfield for ranking purposes; it's still useful internally for relative ordering and for other crawlers, but don't design around the idea that1.0gets you ranked higher.
Lessons learned
Sitemap correctness is a process problem disguised as a technical one. The actual XML is trivial to generate; the hard part is making sure the metadata describing each page stays true as the app grows across quarters and multiple contributors. Three things made the biggest difference in practice:
- Colocating metadata with the route turns "did we update the sitemap" into "did the file compile," which is a much harder thing to skip by accident.
- A visible drift warning beats a silent gap. Nobody reads a changelog entry about sitemap coverage; everybody sees a build warning.
- Locale handling has to be designed in from the start. Retrofitting hreflang into a sitemap that was built single-locale-first is more work than building it multilingual from day one, because every downstream assumption (URL shape, canonical tags, alternate link generation) has to be revisited.
Best practices
- Keep sitemap metadata as close as possible to the route it describes — ideally in the same file.
- Automate a build-time or CI check that flags public routes with no sitemap metadata, even if it's just a warning.
- Generate one sitemap entry per locale, with full hreflang reciprocity including an
x-defaultentry. - Exclude private/admin surfaces at the route-group level, the metadata level, and the
robots.txtlevel — don't rely on any single layer. - Use a sitemap index file once you approach the 50,000 URL limit rather than waiting until you hit it.
- Set
lastmodfrom actual content update timestamps, not the build time. - Attach an XSL stylesheet if you want humans to be able to sanity-check the sitemap without piping it through a formatter.
- Treat
priorityandchangefreqas hints for your own internal consistency, not as ranking levers.
References
- Next.js — Sitemap Metadata File API
- Next.js — Route Handlers
- Google Search Central — Build and Submit a Sitemap
- Google Search Central — Robots Meta Tag
- Google Search Central — Managing Multi-Regional and Multilingual Sites
- MDN — XSLT
- sitemaps.org — Protocol Specification
If you've built a sitemap system that automatically kept itself honest — or one that quietly rotted for a year before anyone noticed — I'd like to hear how it happened. What's the smallest process change that would have caught it earlier?
Top comments (0)