Every URL in our sitemap says 3 October, and 30 of the 76 pages changed after that
Here is a check you can run on our site in one command: curl -s https://nakodo.app/sitemap.xml | grep -o '<lastmod>[^<]*' | sort -u One line comes back. <lastmod>2026-10-03. All 76 URLs, one date, forty-one days
Here is a check you can run on our site in one command:
curl -s https://nakodo.app/sitemap.xml | grep -o '<lastmod>[^<]*' | sort -u
One line comes back. <lastmod>2026-10-03. All 76 URLs, one date, forty-one days ago.
Now open our privacy notice. Under the heading it says Last updated 28 October 2026. The same deployment is telling a human one date and a crawler another, about the same URL, and the gap is twenty-five days.
I went looking for how many of the 76 are in that position. The answer is 30.
Where the date comes from
Nakodo is an app that finds creators for a brand and emails the ones that fit. Its public side is 76 pages, and all 76 are rows in one registry, which is the arrangement I have written about before: sixty-three of our pages are one component. The sitemap is twenty-four lines because of it:
// Every public page, from the registry, each with the date its content last
// changed in substance (not the build date, which would tell crawlers every
// page changed on every deploy).
export default function sitemap(): MetadataRoute.Sitemap {
return [
...STATIC_PAGES.map((p) => ({ url: absoluteUrl(p.path), lastModified: p.updated, priority: p.priority })),
...ENTRIES.map((e) => ({ url: absoluteUrl(e.path), lastModified: e.page.updated, priority: PRIORITY[e.kind] })),
];
}
That comment is me arguing with myself and winning the wrong half. Using the build date would be dishonest, because a deploy that changes one page does not change the other 75. So each page carries its own updated, typed by hand:
/** ISO dates. Change `updated` when the content changes in substance. */
published: string;
updated: string;
/**
* Hand-built public pages. `description` is the line llms.txt gives the page;
* `updated` feeds the sitemap, so change it when the page changes in substance.
*/
Two comments, both of them instructions to a person. Neither is an instruction to a machine.
The commit that set all of them
80a6c42 Update content last-modified dates to 2026-10-03
That is the entire history of these dates. One commit, on 3 October, setting every page to the day I finished the first real content pass. The published dates underneath them are 2026-10-02 on 31 pages and 2026-10-03 on 32, so for half the site updated is one day after publication and for the other half it is the same day.
Nothing has moved a single one since. Forty-one days, fifty-five commits, and updated is a field that was correct once.
What changed after 3 October
I asked git which of the files behind those 76 pages have been touched since:
for f in $(git ls-files 'src/content/**' | grep -v 'test\|registry\|types\|rich\|markdown\|method'); do
git log -1 --format='%cs' -- $f
done | sort | uniq -c
1 2026-10-02
46 2026-10-03
4 2026-10-05
21 2026-10-28
Twenty-five of the 63 content pages were edited after the date they advertise. Five of the thirteen hand-built pages too: /pricing on 19 October, and /, /about, /privacy and /terms on 28 October. Thirty pages of 76.
The 28 October batch is the interesting one, because it is the clearest possible case of what the comment means by substance. It is one commit worth of product change: a creator who says yes used to be introduced to the customer by email, and now the conversation stays inside the app. Twenty-three content files, 39 insertions, 39 deletions, almost all of them one sentence each:
- text: "... When a creator says yes, it introduces you by email and you agree the details from your own inbox. ..."
+ text: "... When a creator says yes, it hands the conversation to you and you agree the details there. ..."
A page that describes a different product behaviour than it did last month has changed in substance by any definition I would defend. You can read the new sentence on the outdoor YouTubers page and then find that same URL in the sitemap still claiming 3 October. That is the whole bug in two tabs.
The test that was watching, and what it was watching
Every content page goes through a generated describe block, which is one of the few parts of this codebase I would call well defended: ten rules for writing a page, title length, a word floor, link resolution, banned characters. The dates are in there:
assert.match(page.published, /^\d{4}-\d{2}-\d{2}$/);
assert.match(page.updated, /^\d{4}-\d{2}-\d{2}$/);
assert.ok(page.updated >= page.published);
Three assertions about a date, and not one of them can fail because of drift. The format is a format. The ordering is arithmetic on two numbers I typed myself. Both hold perfectly on a value that is a year out of date.
This is the shape of the mistake, and it is not really about sitemaps. A test that checks a hand-maintained field is well formed gives you the feeling of coverage over that field. The only assertion that would have caught this one has to reach outside the program, to the file's own history, which is exactly why I did not write it.
The quieter consequence
We announce changes to Bing and the other IndexNow engines every hour, and I wrote about that too: one Redis hash keeps them from announcing twice. The cron compares the sitemap against what was last submitted:
// What was last announced: each page's URL and the date it had then.
const SUBMITTED_KEY = "nakodo:indexnow:submitted";
changed: pages.filter((p) => String(submitted[p.url] ?? "") !== p.lastModified).map((p) => p.url),
In that post I wrote, approvingly, that most hourly runs now submit zero URLs, which is the point. It is still the point. It is also, I now realise, the only thing that run can ever do, because the input it diffs on is a constant. The cron has been running hourly since the same week, which is some nine hundred runs, and the twenty-one content pages that changed on 28 October were never announced to a single engine. The mechanism was right and the signal going into it was dead, which is a more embarrassing kind of correct.
Google does not take IndexNow submissions at all and reads lastmod from the sitemap, where its documented position is that it uses the value when a site's values are consistently accurate and ignores them otherwise. A site where every page claims the same day is the textbook version of "otherwise", so the realistic damage is not a penalty, it is that I am sending a field that carries no information and getting nothing back for it.
What I am going to do about it
Three options, in the order I considered them.
Delete lastmod. Defensible. A field nobody maintains is worse than an absent one, and the sitemap still does its real job, which is enumerating 76 URLs. I do not like it, because the date is genuinely useful on a site where two pages change and seventy-four do not.
Generate it from git at build time. Honest by construction, and it moves the whole problem into the build, which would then need repository history and a mapping from each URL to the files that can change it. That mapping is the interesting half and it is not free: a content page changes when its own file changes, and also when the component that renders it does, and also when the plans file it interpolates a price from does.
Keep the field and make the machine notice. This is the one I am writing. The test suite already iterates every page; it can ask git for the last commit date of each page's own file and fail when the file is newer than the date the page claims. The failure message writes the fix for you, which is the property I want: the diff that changes a sentence cannot be green until the same diff moves the date.
It will not catch the shared-component case. It would have caught twenty-five of the thirty, on the day they happened, which is most of what I needed.
The general shape
A field that must be updated by a human at the same time as a different change will be updated once. I have now found this twice in a fortnight in the same codebase: last week it was a number typed into a user-visible sentence, which had gone stale the same way, and the fix was the same shape, which was to make it impossible to be stale rather than to remember harder.
The tell, in both cases, is a comment in the imperative mood. Change it when the page changes in substance. If the only thing enforcing a rule is a sentence addressed to whoever reads the file next, you do not have a rule. You have a note to yourself, in a file, where notes go to be not read.
Check your own: curl -s https://yoursite/sitemap.xml | grep -o '<lastmod>[^<]*' | sort -u. If one line comes back, we have the same bug.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.