<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Growth Playbooks & Insights | VTurnU]]></title><description><![CDATA[Growth Playbooks & Insights | VTurnU]]></description><link>https://vturnu.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6af890d174daf10e1f6751ce/3e43dd12-990d-48e5-a0cc-79bb4c2ff25c.jpg</url><title>Growth Playbooks &amp; Insights | VTurnU</title><link>https://vturnu.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 15 Nov 2026 18:54:44 GMT</lastBuildDate><atom:link href="https://vturnu.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Honest sitemap dates: fix lastmod with git history or a content hash]]></title><description><![CDATA[Most sitemaps stamp every URL with the build date, so search engines stop trusting lastmod. Check yours and fix it with git or a content hash.]]></description><link>https://vturnu.hashnode.dev/honest-sitemap-lastmod-dates</link><guid isPermaLink="true">https://vturnu.hashnode.dev/honest-sitemap-lastmod-dates</guid><category><![CDATA[SEO]]></category><category><![CDATA[SEO for Developers]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[sitemap]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[B. Vignesh]]></dc:creator><pubDate>Sat, 14 Nov 2026 17:35:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af890d174daf10e1f6751ce/8897b95f-c0ad-4030-afd2-f6da65aa1a39.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If your sitemap gives every URL the same <code>lastmod</code>, and that date happens to be the day of your last deploy, search engines have probably stopped listening to it. Google says it uses <code>lastmod</code> only when the value is <a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap">consistently and verifiably accurate</a>. Bing says the most common mistake it sees is exactly this one: <a href="https://blogs.bing.com/webmaster/2023/2/The-Importance-of-Setting-the-lastmod-Tag-in-Your-Sitemap/">every URL stamped with the date the sitemap was generated</a>, instead of the date the page changed.</p>
<p>This post shows how to check your sitemap with one command, and two ways to make the dates honest: git history for sites built from Markdown files, and a content fingerprint for everything else. All three scripts are plain Node.js with no dependencies. I ran each one against test pages before writing this up.</p>
<h2>Why lastmod is worth getting right</h2>
<p>Search engines already know most of your URLs. The question they ask on every visit is which of those URLs changed since last time. <code>lastmod</code> is your answer.</p>
<ul>
<li><strong>Google</strong> says it uses <code>lastmod</code> as a signal for <a href="https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping">scheduling crawls of URLs it already knows</a>, as long as the value matches reality. If a page changed years ago and the sitemap says yesterday, Google will eventually stop believing the dates. It ignores <code>changefreq</code> and <code>priority</code> completely.</li>
<li><strong>Bing</strong> looked at sitemaps in 2023 and found that 18% of the ones that set <code>lastmod</code> set it wrongly, most often by giving every URL the same generation date. When the dates are always "today", Bing says it may disregard them. In 2025 it added that <code>lastmod</code> is a <a href="https://blogs.bing.com/webmaster/2025/7/Keeping-Content-Discoverable-with-Sitemaps-in-AI-Powered-Search/">key signal for deciding what to recrawl</a>, which affects how quickly updates show up in its search results and in AI answers such as Copilot's. Bing ignores <code>changefreq</code> and <code>priority</code> too.</li>
</ul>
<p>So a correct <code>lastmod</code> helps crawlers spend their visits on the pages that changed. A wrong one gets ignored, and you lose that signal for every page on the site, including the ones you just updated.</p>
<h2>Check your sitemap with one command</h2>
<p>Save this as <code>check-lastmod.mjs</code> and point it at a sitemap URL or a local file. It reads sitemap index files too.</p>
<pre><code class="language-js">// check-lastmod.mjs
// Usage: node check-lastmod.mjs https://example.com/sitemap.xml
//        node check-lastmod.mjs ./dist/sitemap.xml
import { readFile } from 'node:fs/promises';

// W3C Datetime, the format the sitemap protocol asks for.
const W3C = /^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}))?)?)?$/;

async function load(src) {
  if (!/^https?:\/\//.test(src)) return readFile(src, 'utf8');
  const res = await fetch(src);
  if (!res.ok) throw new Error(`${src} answered ${res.status}`);
  return res.text();
}

// Reads a sitemap, or a sitemap index and the sitemaps it lists.
async function collect(src, out = [], depth = 0) {
  const xml = await load(src);
  if (/&lt;sitemapindex[\s&gt;]/.test(xml)) {
    if (depth &gt; 0) return out;
    for (const [, loc] of xml.matchAll(/&lt;sitemap&gt;[\s\S]*?&lt;loc&gt;\s*([^&lt;\s]+)\s*&lt;\/loc&gt;/g)) {
      await collect(loc.replace(/&amp;amp;/g, '&amp;'), out, depth + 1);
    }
    return out;
  }
  for (const [, block] of xml.matchAll(/&lt;url&gt;([\s\S]*?)&lt;\/url&gt;/g)) {
    const loc = block.match(/&lt;loc&gt;\s*([^&lt;]+?)\s*&lt;\/loc&gt;/)?.[1];
    const lastmod = block.match(/&lt;lastmod&gt;\s*([^&lt;]+?)\s*&lt;\/lastmod&gt;/)?.[1] ?? null;
    out.push({ loc, lastmod });
  }
  return out;
}

const urls = await collect(process.argv[2] ?? 'sitemap.xml');
const dated = urls.filter((u) =&gt; u.lastmod);
const invalid = dated.filter((u) =&gt; !W3C.test(u.lastmod));
const future = dated.filter((u) =&gt; Date.parse(u.lastmod) &gt; Date.now() + 86_400_000);

// How many URLs share the most common day?
const perDay = new Map();
for (const { lastmod } of dated) {
  const day = lastmod.slice(0, 10);
  perDay.set(day, (perDay.get(day) ?? 0) + 1);
}
const [topDay = '-', topCount = 0] = [...perDay].sort((a, b) =&gt; b[1] - a[1])[0] ?? [];
const share = dated.length ? topCount / dated.length : 0;
const today = new Date().toISOString().slice(0, 10);

console.log(`URLs: ${urls.length}, with lastmod: ${dated.length}`);
console.log(`Most common day: ${topDay} (${(share * 100).toFixed(1)}% of dated URLs)`);
if (invalid.length) console.log(`Not W3C Datetime: ${invalid.length}, for example "${invalid[0].lastmod}"`);
if (future.length) console.log(`Dated in the future: ${future.length}, for example ${future[0].loc}`);
if (dated.length &gt;= 20 &amp;&amp; share &gt;= 0.8) {
  console.log(topDay === today
    ? 'Likely build time: most URLs claim they changed today.'
    : 'Suspicious: most URLs share one date. Check how it is generated.');
}
</code></pre>
<p>Here is the output for a test sitemap of 40 URLs where the generator stamped the build date on every page, plus one date in the wrong format:</p>
<pre><code class="language-text">URLs: 40, with lastmod: 40
Most common day: 2026-11-14 (97.5% of dated URLs)
Not W3C Datetime: 1, for example "14/11/2026"
Likely build time: most URLs claim they changed today.
</code></pre>
<p>Treat the warning as a reason to look, not a verdict. If you launched 300 pages on the same day and never touched them, sharing a date is correct, and Bing says as much. What gives a sitemap away is a date that moves forward for every URL on every deploy.</p>
<h2>Decide what counts as a change</h2>
<p>Before fixing anything, agree on what a "change" is. Google's documentation is specific: the date should reflect the last <strong>significant</strong> update, meaning a change to the main content, the structured data or the links on the page. A new copyright year in the footer is not one.</p>
<table>
<thead>
<tr>
<th>Change</th>
<th>Update lastmod?</th>
</tr>
</thead>
<tbody><tr>
<td>Rewrote a section or added new information</td>
<td>Yes</td>
</tr>
<tr>
<td>Changed structured data (price, author, product details)</td>
<td>Yes</td>
</tr>
<tr>
<td>Added, removed or changed links in the content</td>
<td>Yes</td>
</tr>
<tr>
<td>New footer year, cookie banner or menu item</td>
<td>No</td>
</tr>
<tr>
<td>New CSS or JavaScript bundle, same words</td>
<td>No</td>
</tr>
<tr>
<td>Fixed a typo</td>
<td>Your call. Skipping it keeps the signal clean</td>
</tr>
</tbody></table>
<p>Both fixes below follow this table.</p>
<h2>Fix 1: take the date from git</h2>
<p>If each page comes from one content file (Markdown, MDX, a YAML or JSON entry), the last commit that touched that file is a good <code>lastmod</code>.</p>
<pre><code class="language-js">// git-lastmod.mjs
import { execFileSync } from 'node:child_process';

const git = (...args) =&gt; execFileSync('git', args, { encoding: 'utf8' }).trim();

// A shallow clone only knows the last commit, so every file would get its date.
if (git('rev-parse', '--is-shallow-repository') === 'true') {
  throw new Error('Shallow clone: fetch full history (fetch-depth: 0) before building the sitemap.');
}

// Date of the last commit that touched this file, following renames and
// skipping commits whose message contains [skip-lastmod].
export function gitLastmod(file) {
  const date = git(
    'log', '-1', '--follow', '--format=%cI',
    '--invert-grep', '--fixed-strings', '--grep=[skip-lastmod]',
    '--', file,
  );
  return date || null; // null for files git has never seen: leave lastmod out
}
</code></pre>
<p>Then, wherever your generator builds the sitemap entry:</p>
<pre><code class="language-js">import { gitLastmod } from './git-lastmod.mjs';

const lastmod = gitLastmod(`content/blog/${slug}.md`);
</code></pre>
<p>A few details that matter:</p>
<ul>
<li><code>%cI</code> gives the commit date in strict ISO 8601 with a time zone, which is valid W3C Datetime. Bing asks for date and time where you have it.</li>
<li><code>--follow</code> keeps the history when a file is renamed. A rename is still a commit, though, so the page gets the rename date. If nothing changed in the text, put <code>[skip-lastmod]</code> in that commit message.</li>
<li>The same tag works for typo fixes and formatting commits. In my test repo, a typo fix tagged this way left the page on its earlier date, as intended.</li>
<li>A file git has never seen returns <code>null</code>. Leave <code>lastmod</code> out for that URL instead of guessing. Google says it's fine to include <code>lastmod</code> only for the pages you're confident about.</li>
</ul>
<p><strong>The CI trap.</strong> GitHub's <code>actions/checkout</code> fetches a single commit by default. In a shallow clone, every file looks like it last changed in the latest commit, which brings back the build-date problem through a side door. Fetch the full history:</p>
<pre><code class="language-yaml">- uses: actions/checkout@v7
  with:
    fetch-depth: 0
</code></pre>
<p>The script refuses to run in a shallow clone for this reason. Check your hosting platform's build settings too, since some of them clone with limited history.</p>
<p>Git dates have one limit: pages assembled from many sources, like a category page that lists posts or a page fed by a CMS or a database, don't map to one file. That's where the second fix comes in.</p>
<h2>Fix 2: fingerprint the built page</h2>
<p>This one works with any generator or CMS, because it looks at the output. After the build, it hashes only the parts of each page that count as a significant change: the text inside <code>&lt;main&gt;</code>, the links inside <code>&lt;main&gt;</code>, and the JSON-LD. It keeps a small manifest from the previous build. A URL gets a new date only when its hash changes.</p>
<pre><code class="language-js">// build-sitemap.mjs
// Usage: node build-sitemap.mjs &lt;dist folder&gt; &lt;site origin&gt;
// Example: node build-sitemap.mjs dist https://example.com
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';

const DIST = process.argv[2] ?? 'dist';
const SITE = process.argv[3] ?? 'https://example.com';
const MANIFEST = 'lastmod-manifest.json'; // keep it between builds: commit it or cache it

async function* htmlFiles(dir) {
  for (const entry of await readdir(dir, { withFileTypes: true })) {
    const path = join(dir, entry.name);
    if (entry.isDirectory()) yield* htmlFiles(path);
    else if (entry.name.endsWith('.html') &amp;&amp; entry.name !== '404.html') yield path;
  }
}

// dist/blog/post/index.html -&gt; https://example.com/blog/post/
function urlFor(file) {
  const path = '/' + relative(DIST, file).split(sep).join('/');
  return new URL(path.replace(/index\.html$/, '').replace(/\.html$/, ''), SITE).href;
}

// JSON-LD without dateModified, so the date we write never changes the hash.
function structuredData(html) {
  const drop = (v) =&gt; Array.isArray(v) ? v.map(drop)
    : v &amp;&amp; typeof v === 'object'
      ? Object.fromEntries(Object.entries(v).filter(([k]) =&gt; k !== 'dateModified').map(([k, x]) =&gt; [k, drop(x)]))
      : v;
  return [...html.matchAll(/&lt;script[^&gt;]*application\/ld\+json[^&gt;]*&gt;([\s\S]*?)&lt;\/script&gt;/gi)]
    .map(([, json]) =&gt; { try { return drop(JSON.parse(json)); } catch { return json.trim(); } });
}

// What counts as a significant change: the main text, the links in it, structured data.
// Header, footer, sidebar, scripts and styles are left out on purpose.
function fingerprint(html) {
  const main = html.match(/&lt;main[\s\S]*?&lt;\/main&gt;/i)?.[0] ?? html;
  const links = [...main.matchAll(/href\s*=\s*["']([^"']+)["']/gi)].map(([, href]) =&gt; href);
  const text = main
    .replace(/&lt;(script|style|noscript)[\s\S]*?&lt;\/\1&gt;/gi, ' ')
    .replace(/&lt;[^&gt;]+&gt;/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  return createHash('sha256').update(JSON.stringify({ text, links, data: structuredData(html) })).digest('hex');
}

// Only indexable, self-canonical pages belong in a sitemap.
function belongsInSitemap(html, url) {
  if (/&lt;meta[^&gt;]+name=["']robots["'][^&gt;]+noindex/i.test(html)) return false;
  const canonical = html.match(/&lt;link[^&gt;]+rel=["']canonical["'][^&gt;]*&gt;/i)?.[0].match(/href=["']([^"']+)["']/i)?.[1];
  return !canonical || new URL(canonical, SITE).href === url;
}

const firstRun = !existsSync(MANIFEST);
const previous = firstRun ? {} : JSON.parse(await readFile(MANIFEST, 'utf8'));
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const next = {};

for await (const file of htmlFiles(DIST)) {
  const html = await readFile(file, 'utf8');
  const url = urlFor(file);
  if (!belongsInSitemap(html, url)) continue;
  const hash = fingerprint(html);
  const before = previous[url];
  if (before &amp;&amp; before.hash === hash) next[url] = before; // unchanged: keep the old date
  else if (before) next[url] = { hash, lastmod: now }; // real change
  else next[url] = { hash, lastmod: firstRun ? null : now }; // new page, or no history yet
}

const esc = (s) =&gt; s.replace(/&amp;/g, '&amp;amp;').replace(/&lt;/g, '&amp;lt;').replace(/&gt;/g, '&amp;gt;');
const entries = Object.entries(next).sort(([a], [b]) =&gt; a.localeCompare(b)).map(([loc, { lastmod }]) =&gt;
  `  &lt;url&gt;\n    &lt;loc&gt;${esc(loc)}&lt;/loc&gt;\n${lastmod ? `    &lt;lastmod&gt;${lastmod}&lt;/lastmod&gt;\n` : ''}  &lt;/url&gt;`);

await writeFile(join(DIST, 'sitemap.xml'),
  `&lt;?xml version="1.0" encoding="UTF-8"?&gt;\n&lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&gt;\n${entries.join('\n')}\n&lt;/urlset&gt;\n`);
await writeFile(MANIFEST, JSON.stringify(next, null, 2) + '\n');
console.log(`${entries.length} URLs written${firstRun ? ' (first run: no lastmod until a page changes)' : ''}`);
</code></pre>
<p>How it behaved in my tests, build after build:</p>
<ul>
<li>A new footer year on one page: same date as before.</li>
<li>A new <code>dateModified</code> in the JSON-LD and nothing else: same date. The script strips <code>dateModified</code> before hashing, otherwise the date you write would change the hash on every build.</li>
<li>A rewritten paragraph: new date for that page only.</li>
<li>A changed headline in the structured data: new date.</li>
<li>A new page: dated on the first build where it appears.</li>
<li>Pages marked <code>noindex</code>, the 404 page and a print version whose canonical pointed elsewhere: left out, because a sitemap should list only canonical, indexable URLs.</li>
</ul>
<p>Two things to plan for:</p>
<ol>
<li><strong>The first run.</strong> With no manifest there is no history, so the script writes no dates at all. That is deliberate: no date is better than a made-up one. From the next build on, every real change gets a timestamp.</li>
<li><strong>Keeping the manifest.</strong> Commit <code>lastmod-manifest.json</code>, or store it in your CI cache. If it ever gets lost, you fall back to the first-run state (no dates), not to wrong dates.</li>
</ol>
<p>The regular expressions are fine for HTML your own build produces. If your markup is messy or comes from many hands, swap in a real HTML parser for the <code>&lt;main&gt;</code> extraction.</p>
<h2>Keep your other dates in step</h2>
<p>Your sitemap is not the only place a page states its age. If you show "Updated on" in the page and set <code>dateModified</code> in your structured data, take all three from the same value. Google's documentation talks about checking <code>lastmod</code> against the page itself, so a sitemap that says one thing while the page says another is a fast way to lose trust in both.</p>
<p>The same goes for refreshing dates to look fresh. Changing the date without changing the page is exactly the pattern both search engines say they learn to ignore.</p>
<h2>The ping is gone, so how do engines find out?</h2>
<p>Google <a href="https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping">retired the sitemap ping endpoint</a> in 2023, and requests to it now return a 404. Reference your sitemap in <code>robots.txt</code> with a <code>Sitemap:</code> line and submit it in Search Console. Bing reads sitemaps from <code>robots.txt</code> or Bing Webmaster Tools and says it typically rechecks them at least once a day. For changes you want noticed right away, Bing recommends <a href="https://www.indexnow.org/">IndexNow</a>, which other participating search engines use too.</p>
<p>Search engines will keep rereading your sitemap either way. An honest <code>lastmod</code> is what makes each reread useful.</p>
<h2>A short checklist</h2>
<ul>
<li>Run <code>check-lastmod.mjs</code> on your live sitemap.</li>
<li>Write down what counts as a significant change on your site.</li>
<li>Use git dates for pages that come from one file, and the fingerprint for the rest.</li>
<li>Fetch full git history in CI.</li>
<li>Drop <code>changefreq</code> and <code>priority</code> if you like. Google and Bing both ignore them.</li>
<li>Use one date for the sitemap, the visible "Updated" line and <code>dateModified</code>.</li>
</ul>
<hr />
<p><em>I'm B. Vignesh, Founder &amp; CEO of <a href="https://vturnu.com">VTurnU</a>, a B2B digital marketing and web development company. I write about the technical details that decide whether search engines and AI assistants can find a website. This post was written with the help of AI tools; every script in it was run and tested on Node.js 24 before publishing.</em></p>
]]></content:encoded></item></channel></rss>