A static site generator writes every file on every run. That means every file’s modification time is the moment the build finished, which means a sitemap generated from fs.statSync(file).mtime says that all 2,194 pages on the site changed today. Tomorrow it will say all 2,194 pages changed tomorrow. It has said the same thing every morning since the site existed.

That is not a subtle bug. It is a field that carries zero bits of information, dressed up as a field that carries a date, and search engines are entirely within their rights to ignore it. Google’s own sitemap documentation says it uses the <lastmod> value “if it’s consistently and verifiably (for example by comparing to the last modification of the page) accurate” — and “verifiably accurate” is exactly what a build stamp is not.

So what should <lastmod> say, and how do you work it out without standing up a database to remember what your pages used to look like?

What lastmod is answerable to

The specification’s wording is “the date of last modification of the page”. The useful reading, the one a crawler can act on, is: the last time this page’s meaning changed. Not the last time its bytes were written. Not the last time the deploy ran. Not the last time the footer year ticked over.

That gives a test you can actually implement. Two builds of a page are the same modification if a reader would learn nothing new from the second one. Reflow the markup, rename a CSS class, reorder an attribute, regenerate an inline script — a reader learns nothing. Change a number in a table, add a paragraph, fix a wrong sentence — a reader learns something, and the date should move.

The signature: hash what the page says

The whole mechanism is one function. Strip the page down to the text a human would actually read, and hash that. This is the version that dates the sitemap on weatherhack.com, a generated weather-reference site of mine, and it is short enough to read in full:

// lastmod-signature.js — Node builtins only, no dependencies.
const { createHash } = require('node:crypto');

// The signature a lastmod date is answerable to: what the page SAYS,
// stripped of how it says it.
function lastmodSignature(html) {
  const text = String(html)
    .replace(/<script[\s\S]*?<\/script>/gi, ' ') // JS *and* JSON-LD
    .replace(/<style[\s\S]*?<\/style>/gi, ' ')   // CSS
    .replace(/<!--[\s\S]*?-->/g, ' ')            // comments, build stamps included
    .replace(/<[^>]+>/g, ' ')                    // every tag, attributes with them
    .replace(/\s+/g, ' ')                        // indentation and newline churn
    .trim();
  return createHash('sha256').update(text).digest('hex');
}

module.exports = { lastmodSignature };

Five replacements and a hash. What survives is the visible prose, in order, with runs of whitespace flattened to single spaces. What does not survive: every tag and therefore every attribute, every comment, every stylesheet, every script block, and all indentation. A pretty-printer run across the whole template moves not one signature.

The output is a SHA-256 digest — 64 hexadecimal characters — and it is stored as-is, because the point of keeping it is to compare it with the next build’s digest, not to be short.

The ledger: one committed file, one line per page

A hash is only useful if you remember the last one. The store is a single JSON file, html-lastmod.json, committed to the repository beside the source:

{
 "html/weather/tokyo/march/index.html": {"sha": "e8124fcd…", "date": "2026-09-13"},
 "html/weather/tokyo/index.html": {"sha": "311d251f…", "date": "2026-09-13"}
}

It is written one entry per line on purpose. A ledger that pretty-prints nested objects produces a diff nobody reads; one line per page produces a diff where the review question — which pages moved, and to when? — is answered by git diff --stat and then by eye.

The build reads it at the start, writes it at the end, and the resolution rule in between is four lines:

// resolve-lastmod.js — decide one page's <lastmod> against the committed ledger.
const ISO = /^\d{4}-\d{2}-\d{2}$/;

function resolveLastmod(rel, html, ledger, today, seedFromGit) {
  const sha = lastmodSignature(html);
  const rec = ledger[rel];
  if (rec && rec.sha === sha && ISO.test(rec.date)) {
    return { sha, date: rec.date }; // text unchanged: reuse the stored date verbatim
  }
  if (!rec) {
    return { sha, date: seedFromGit(rel) || today }; // never seen: ask git history
  }
  return { sha, date: today }; // the text moved in THIS build, so today is true
}

module.exports = { resolveLastmod };

The stored date is reused verbatim on the unchanged path. That is the entire difference from an mtime, and because the file is committed, it survives a clean checkout, a new machine, a cache wipe and a git clean -xdf. There is no state living in a build directory that a CI runner can forget.

What that produces on a real site, read off consecutive commits of the ledger on 2026-09-13: a build that folded some long comparison tables moved 45 of 2,194 rows. The next build, which added a caption to a table, moved 110. The remaining 2,149 and 2,084 pages kept the dates they already had, because their visible text was byte-identical to the previous build’s.

And a build that should move everything does. The commit that added a visible “last updated” line to every month page moved 1,824 rows in one go — because that genuinely is a change to 1,824 pages, and the ledger has no opinion about whether a change is large or small, only whether it happened.

Right now that ledger holds five distinct dates across its 2,194 entries: 22 pages last changed on 2026-09-08, 27 on 2026-09-09, 11 on 2026-09-11, 155 on 2026-09-12, and 1,979 on 2026-09-13 — that last bucket being the day of the template change above. A crawler reading this sitemap gets a spread of dates that means something. The mtime version got one date, repeated 2,194 times, changing daily.

The self-reference trap

This is the part that will bite you, and it is not obvious until it has been biting for a week.

The page prints its own last-updated date. That is the whole point of computing one — visitors like knowing, and the same date goes into dateModified in the page’s JSON-LD. But that printed date is page content. If you hash the page as it ships, the date is inside the thing being hashed. Print today’s date, hash it, get a new signature, conclude the page changed, write today’s date. Tomorrow: print tomorrow’s date, new signature, page “changed” again. Every page changes every day, forever, and you have rebuilt the exact bug you set out to remove — only now it is self-inflicted and has a plausible-looking implementation behind it.

There are two halves to the fix and you need both.

The machine-readable half is free: dateModified lives inside <script type="application/ld+json">, and the signature function strips every <script> block before hashing. A JSON-LD date can never feed back into the signature that produced it, by construction, and that property is worth a comment in the code so nobody “tidies” the strip away later.

The visible half is not free, because prose survives the strip — it has to, or the function would be hashing nothing. So the rule is that the visible date may never come from the build clock. The generator reads the committed ledger before it renders anything and prints the date the ledger already holds for that page. new Date() in that template is the bug, written in a way that looks like a feature.

When there is no ledger yet

A brand-new page, or the first build after someone deletes the file, has no record to reuse. Stamping those with today is tempting and wrong: it would announce a page as fresh on the day you happened to add the mechanism, not on the day the page was last actually edited.

Git already knows. The seeding path walks git log --name-only for every commit that touched each page, newest first, reads the historical blobs in batches of 256 through a single git cat-file --batch, and hashes each one with the same signature function. The date it wants is the newest commit at which the page’s signature differs from the newer version it already examined — that is the commit where the change actually landed. A page whose text has never moved since it was created gets the date of the commit that created it.

Exactly one case falls back to today: a page with no git history at all, which is to say a page this build has just invented and nobody has ever committed. That is honest — it really is new today. (There is one other route to the same place, and it is worth writing down rather than hiding: if the git call itself fails — a tarball export, a shallow clone with no history — the seeder gives up, prints a warning naming the count, and stamps that whole batch with today. A build that cannot ask the question should say so loudly, not guess quietly.) The build prints the count in each of the three buckets, so “seeded from today” can never quietly become the common path without somebody seeing the number.

The settle gotcha

The ledger is read at the start of the build and written at the end. That ordering is forced — you cannot know a page’s new signature until the page has been rendered — and it has one visible consequence that will look like a bug when you hit it.

On the build that first introduces a change to a page, the page renders the date the ledger held when the build began, while the sitemap written at the end of the same build gets the new date. For one build, the visible date and the <lastmod> disagree. Run the build a second time with no further edits and it settles: the page now reads the advanced date out of the ledger, prints it, and its signature does not move again.

So the working rule for anything that changes what pages print is: build twice, commit the second one. On any later day with no edits, the text does not move, the signature does not move, the date does not move, and one build is one build.

What this deliberately cannot see

A signature over visible text ignores changes that are not visible text. That is a trade, not a feature, and it cuts both ways:

  • A CSS-only change does not move the date. Reskin the site, change the type scale, make every page look different to a human — every signature is identical and every <lastmod> stays put. If your redesign is the change you want crawlers to come back for, this mechanism will not tell them.
  • Markup-only changes do not move it either. Adding a <table> around content that was already a list, fixing heading levels, adding alt text, adding schema markup — all invisible to the hash. Accessibility and structured-data work is real work that this field will not report.
  • Attribute content is text a user can see. alt text and title attributes are stripped with their tags, so an image whose alt text was wrong and is now right reads as unchanged.
  • It is per-page, not per-site. Changing a shared footer’s wording moves every page’s signature at once, and the ledger will faithfully advance every date — correctly, but it looks alarming in a diff.

The version worth shipping depends on which of these you would rather be wrong about. I would rather under-report than cry wolf every morning, so visible text is where I drew the line — but if you serve an app where the markup is the product, hash the markup instead and accept the churn.

What I have not measured

I have not measured a search effect from any of this, and I am not going to imply one. The honest version of why is boring: I have no instrument that could see it. The only search data I collect is referrer counts on arriving visitors — no crawl logs, no impression data, no webmaster-tools enrolment on any engine — and a lastmod change would show up first as a change in crawl behaviour, which is precisely the layer I cannot see. On top of that there is no control group: the site’s inventory changed inside the same window and its query demand is seasonal, so even a visible move would have two other explanations queued up behind it. Anyone telling you what an accurate lastmod is worth in positions or clicks is telling you something they did not measure either.

What I can say is narrower and true: the field was false on 2,194 URLs every single day, and now it is not. Whether a crawler cares is the crawler’s business.

For the one adjacent thing I did try to measure, and the receipts for why it came out inconclusive, see does IndexNow do anything? — same site, same sitemap, same problem of trying to prove a negative with no instrument.

More build notes and postmortems: the DankDev blog.

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top