Notifio's site has 38 indexable URLs. You can count them yourself:
$ curl -s https://notifio.app/sitemap.xml | grep -c "<url>" 38 Ten of those are written out as object literals. The other 28 are generated from the arrays that already exist for other reasons, and the reason that split falls where it does is the whole content of this post.
The ten
const STATIC_ROUTES: MetadataRoute.Sitemap = [ { url: `${APP_URL}/`, lastModified: new Date("2026-09-14"), changeFrequency: "weekly", priority: 1.0 }, { url: `${APP_URL}/pricing`, lastModified: new Date("2026-07-21"), changeFrequency: "monthly", priority: 0.9 }, { url: `${APP_URL}/download`, lastModified: new Date("2026-09-14"), changeFrequency: "weekly", priority: 0.8 }, { url: `${APP_URL}/alerts`, lastModified: new Date("2026-09-14"), changeFrequency: "weekly", priority: 0.9 }, { url: `${APP_URL}/compare`, lastModified: new Date("2026-09-14"), changeFrequency: "monthly", priority: 0.9 }, { url: `${APP_URL}/guides`, lastModified: new Date("2026-09-14"), changeFrequency: "weekly", priority: 0.8 }, { url: `${APP_URL}/help`, lastModified: new Date("2026-07-21"), changeFrequency: "monthly", priority: 0.7 }, { url: `${APP_URL}/about`, lastModified: new Date("2026-10-03"), changeFrequency: "monthly", priority: 0.6 }, { url: `${APP_URL}/terms`, lastModified: new Date("2026-10-03"), changeFrequency: "yearly", priority: 0.3 }, { url: `${APP_URL}/privacy`, lastModified: new Date("2026-10-03"), changeFrequency: "yearly", priority: 0.3 }, ]; The comment above it draws the line explicitly:
The hand-maintained list below is only for pages that exist as their own route.
That is the rule. If there is a page.tsx on disk at that path, somebody wrote the page by hand and can write one line here. If the page comes out of an array, the array generates the entry.
The 28
export default function sitemap(): MetadataRoute.Sitemap { const alertPages = ALERT_SITES.map((site) => ({ url: `${APP_URL}/alerts/${site.slug}`, lastModified: new Date("2026-09-14"), changeFrequency: "monthly", priority: 0.8, })); const comparePages = COMPARE_TARGETS.map((target) => ({ ... })); const articlePages = ALL_ARTICLES.map((article) => ({ url: `${APP_URL}${articleHref(article)}`, ... })); return [...STATIC_ROUTES, ...alertPages, ...comparePages, ...articlePages]; } 15 rental site pages, 5 competitor comparisons, 8 long-form articles. 10 + 15 + 5 + 8 = 38, which is the number grep -c gave back, and that arithmetic holding is the only test this file has.
The comment on the first one says what the pattern buys:
The per-site alert pages come straight from the catalogue in
lib/alerts, so adding a site is one edit in one file and it cannot be half-added: the page, the hub grid, the footer column and this list all read the same array.
"Cannot be half-added" is the point. The failure mode this removes is not a build error. It is a page that exists, renders, ranks for nothing, and never shows up in Search Console because somebody added a sixteenth entry to the catalogue and did not think about the sitemap. There is no reminder that would have caught that. Deriving the list removes the need for one.
The article case is the one that needed a helper, because those pages live in two URL spaces:
export function articleHref(article: Article): string { return article.kind === "audience" ? `/for/${article.slug}` : `/guides/${article.slug}`; } ALL_ARTICLES is the concatenation of three audience pages and five guides, and a kind field on each decides which prefix it gets. The sitemap does not know about either route. It asks the article where it lives.
The one field that is not a lastModified date
Look at the changeFrequency on the comparison pages. It is monthly, same as the alerts. The comment explains why that is not laziness:
Comparison pages quote other companies' prices, so they carry a higher change frequency than their edit rate suggests: a stale price on one of those is worse than a stale sentence anywhere else on the site.
Pages like notifio.app/compare/rentbird contain somebody else's pricing. We did not set that price and cannot be told when it changes. The page carries a citation to the source so a reader can check, but the crawl hint is the other half of that: we would rather Google come back and find it corrected than cache a number that has moved. The hint is advisory and search engines are free to ignore it, which is fine, because the cost of asking is a word in an XML file.
lastModified is a hardcoded date rather than a file mtime or a build timestamp, which is the deliberately boring choice. A build timestamp would claim every page changed on every deploy, which is false and trains a crawler to stop believing the field. A hardcoded date is wrong only when somebody forgets to update it, and being behind is a smaller lie than being wrong every single time.
robots.txt is four lines and two of them matter
export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: "*", allow: "/", disallow: ["/api/", "/success/"], }, ], sitemap: `${APP_URL}/sitemap.xml`, }; } $ curl -s https://notifio.app/robots.txt User-Agent: * Allow: / Disallow: /api/ Disallow: /success/ Sitemap: https://notifio.app/sitemap.xml /api/ is obvious. /success/ is the post-checkout page, and it is blocked twice. Once here, and once in its own metadata:
export const metadata: Metadata = { title: "Payment Successful", robots: { index: false, follow: false }, }; Belt and braces, and the two are not redundant in the way they look. Disallow asks a crawler not to fetch the page at all; noindex tells a crawler that did fetch it not to list it. A page blocked only by robots.txt can still appear in results if something links to it, because the crawler obeys the fence and indexes the URL from the link text instead. A page with only noindex gets fetched every time. Doing both means the page that says "your activation token is on its way" never turns up as a search result for somebody who did not buy anything.
Neither path is in the sitemap, and that on its own is not a control. A sitemap is an invitation, not a fence.
Note what is not disallowed: nothing in the rental-site catalogue, nothing under /compare/, nothing under /for/ or /guides/. Those are the pages the site exists to have read. notifio.app/alerts is the hub for the fifteen, notifio.app/guides for the eight, and both are in the handwritten ten because both are a real file.
The bit that is still fragile
APP_URL comes from an env var with a fallback:
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://notifio.app"; Same line, in fifteen files: sitemap.ts, robots.ts, the root layout, both checkout routes, and every page that emits structured data. It would be one shared constant in a tidier codebase. The reason it is not is that the fallback is the thing you want to read at the top of the file you are debugging, and a sitemap that emits http://localhost:3000 URLs into production is the kind of mistake you want visible rather than one import away.