DEV Community

Mohamed Gado
Mohamed Gado

Posted on Originally published at mohamedgado.com on

Next.js sitemap lastmod never changes: derive the date from git, not a sync timestamp

My Next.js sitemap listed the same lastmod for four project pages, 2026-10-15, on the day I rewrote their titles and descriptions. The date came from a syncedAt field in each page's content file, and that field only changes when a sync job runs. Pages I edit by hand never sync. I fixed it by computing the date from git: the day of the newest commit that changed anything other than the date line, or today if the file has uncommitted edits.

Why does my sitemap lastmod never change?

app/sitemap.ts in the App Router returns objects with a lastModified field, and Next.js writes it out as <lastmod>. Whatever value you return is what search engines see. So the question is where the value comes from.

In my case each project page has a markdown sidecar with a syncedAt timestamp, and the sitemap used it:

// app/sitemap.ts
return {
  url: `${baseUrl}/projects/${slug}`,
  lastModified: meta?.lastModified ?? nowData.lastUpdated,
  changeFrequency: 'monthly' as const,
  priority: 0.6,
}
Enter fullscreen mode Exit fullscreen mode

Copy

Some sidecars are filled by a sync job that updates syncedAt. Others are written by hand and never re-sync. On 2026-11-11 I found 11 of them older than their last edit, one by five months. Nafis, Rafiq and Hadeed still said 2026-10-15, and Masareef said 2026-11-06, the same day all four got new titles and descriptions.

Does Google use the lastmod date?

Google says it uses lastmod when it is consistently and verifiably accurate, and that it ignores the value when it is not. A date that never moves is one kind of inaccurate. Stamping every URL with the build date is the other kind, and it gets the same result, because the date then changes on every deploy whether the page did or not.

I had two reasons to care. The URL Inspection "Request Indexing" quota is small, and it was already used up that day by other work, so the date was the only re-crawl signal left. And a wrong date is worse than none, because it teaches the crawler to skip the field.

How do I get a real last-modified date from git?

Ask git when the file last changed. Two details make it work.

First, the date field is not content. If a script moves syncedAt forward and commits it, that commit becomes the file's newest, and the next run sees a fresh change. The check has to ignore the date line, both in the working tree and in history.

// lib/sidecar-lastmod.ts
const SYNCED = /^syncedAt:.*$/m

function withoutDate(src: string): string {
  return src.replace(SYNCED, '').replace(/\r\n/g, '\n')
}

/** YYYY-MM-DD the file last changed: today when its content has uncommitted edits. */
function lastChange(file: string, name: string): string {
  const committed = git(['show', `HEAD:content/projects/${name}`])
  if (withoutDate(readFileSync(file, 'utf8')) !== withoutDate(committed)) {
    return today()
  }
  // Newest commit that changed more than the date line.
  const commits = git(['log', '--format=%H %cs', '--', file])
    .trim()
    .split('\n')
    .filter(Boolean)
  for (const entry of commits) {
    const [sha, date] = entry.split(' ')
    const lines = git(['show', '--format=', '-U0', sha, '--', file]).split('\n')
    const edited = lines.some(
      (l) =>
        /^[+-]/.test(l) && !/^(\+\+\+|---)/.test(l) && !/^[+-]syncedAt:/.test(l)
    )
    if (edited) return date
  }
  return commits.at(-1)?.split(' ')[1] ?? ''
}
Enter fullscreen mode Exit fullscreen mode

Copy

It compares the working file with HEAD first, so an edit you have not committed yet counts as today. Then it walks the commits for that file, newest first, and returns the date of the first one whose diff has a changed line that is not syncedAt. --format=%cs gives the commit date as YYYY-MM-DD.

Second, git here is execFileSync with an argument array, so file names never pass through a shell.

Where should the check run?

Not in the build. Vercel clones the repository shallowly, so a build has no history to read, and the git dates there would all look recent. This runs on my machine in two places. A script moves stale dates forward:

pnpm lastmod:sidecars
Enter fullscreen mode Exit fullscreen mode

Copy

And a test fails while any date is behind, so I cannot push a stale one by accident. The sitemap itself keeps reading syncedAt from the file, which means the build stays fast and has no git dependency.

What went wrong on the way

  • The first version counted any uncommitted change as an edit, so re-dating a file made it stale again. The committed version had the same problem one step later, as described above. Ignoring the date line fixed both.
  • I counted stale files with a shell loop piped into head. It died on SIGPIPE and reported 8 when there were 11. Do not count through head.

How do I check the result?

Fetch the live sitemap and read the entries for the pages you changed:

curl -s https://www.mohamedgado.com/sitemap.xml
Enter fullscreen mode Exit fullscreen mode

Copy

Each <lastmod> should match the day you last edited that page. Then re-submit the sitemap in Search Console. Mine was read the same day, with 106 pages.

The rule I kept: a sitemap date is only worth sending if it moves when the page does.

The site this came from is described in the portfolio case study. For another indexing problem on the same site, see Discovered, currently not indexed in Google Search Console.

Top comments (0)