Munchable's sitemap is a sixty line TypeScript function that returns an array. munchable.app/sitemap.xml is what Next makes of it: 426 URLs, 56 KB, and exactly one <lastmod> element in the whole document.
It used to be considerably more machinery than that, and the interesting part is what deleting the machinery left behind.
What was there before
The site once had a page per product, which meant tens of thousands of URLs. That shape forces a different design:
- A single sitemap is capped at 50,000 URLs or 50MB uncompressed, so the set had to be split and fronted by a sitemap index.
- The URLs came out of the database, so the child routes could not be static.
- A dynamic route that runs a query is a route a crawler can hammer, so it needed its own CDN cache header to stop the expensive ranking query being re-run on every fetch.
That is an index route, chunked child routes, about 120 lines of XML serialisation, and a cache policy. All of it correct, none of it interesting, and every line of it a place to get entity escaping or a content type subtly wrong.
Then the product pages went away. What remained were pages that come from code rather than from rows: seven condition guides, a few hundred generated question pages, 38 recipes, and a handful of static pages. At that size the framework convention does the entire job:
export default function sitemap(): MetadataRoute.Sitemap {
if (!IS_INDEXABLE) return [];
const now = new Date();
return [
{ url: absoluteUrl('/'), lastModified: now, changeFrequency: 'weekly', priority: 1.0 },
{ url: absoluteUrl('/conditions'), changeFrequency: 'monthly', priority: 0.8 },
...CONDITION_PAGES.map((page) => ({ /* ... */ })),
...ANSWER_PAGES.map((page) => ({ /* ... */ })),
...RECIPE_PAGES.map((page) => ({ /* ... */ })),
// ...
];
}
Next prerenders it, owns the escaping and sets the content type. The response comes back as application/xml with an etag, and nothing in the request path touches a database.
The empty array is the part that earns its keep
if (!IS_INDEXABLE) return [];
Every preview and branch deploy gets its own hostname, and is functionally identical to production. app/robots.ts already disallows everything on those hosts. If the sitemap still published the full URL set there, the two files would be making opposite claims on the same deploy: crawl nothing, and here are 426 things to crawl. The empty array keeps them in agreement, and it is one line rather than an environment variable in a dashboard.
Two of the four fields are decoration
Every entry carries changeFrequency and most carry priority. Google's own documentation is unambiguous about both: it "ignores <priority> and <changefreq> values". So those fields exist for other consumers, and in practice for nobody at all.
I have left them in, which is a choice worth being honest about rather than dressing up. The weak argument for keeping them is that non-Google crawlers may read them. The real reason is that they encode intent in the one file where the whole URL set is visible at once: the condition pages are the highest priority thing on the site, the generated answers come next, and the legal pages sit at 0.3. Nobody downstream cares. It still reads as a map of what matters.
If you want the version of this that is defensible, delete both fields and write that ordering in a comment.
The one lastmod is a build timestamp
Exactly one URL in the file gets a lastModified, and it is the homepage:
{ url: absoluteUrl('/'), lastModified: now, ... }
now is new Date() evaluated inside a function that is prerendered, so the timestamp in the published XML is the moment the last production build ran. Today it reads 2026-10-03T15:09:28.124Z.
Google uses lastmod only "if it's consistently and verifiably accurate", and says it should reflect the last significant update to the page. A timestamp that moves whenever any deploy happens is neither, which is why none of the other 425 URLs have one. The generated pages have a date that would be meaningful, the date their underlying data changed, and until that is plumbed through, saying nothing is more accurate than saying "the last time I ran a build".
The comment has been wrong twice
The docblock above the function tells you how many entries to expect. When it was written it said "about 320 entries". It currently says "about 190". The file emits 426. A second sentence in the same comment says the answer index exists so a crawler reaches "all 173" of those pages, and there are now 373 of them.
Nothing in the file reads those numbers, so nothing caught them. The reasons in that comment, why there is no index, why previews publish nothing, are still exactly right, and they are the part a comment is good at. The counts should have been a test, or absent.
See the output rather than the code
The three generated sets are all public, so you can check the file against them. munchable.app/answers is the index of the question pages, which are the bulk of those 426 URLs. munchable.app/recipes is the 38 recipe pages, one of which is a rice congee. munchable.app/conditions is the seven guides that sit at the top of the priority order nobody reads.
One function, one array, no chunking, and the most load-bearing line in it returns nothing at all.
Top comments (0)