Updating Sitemap Dates Only for Routes That Actually Changed
Why this matters
A dynamic sitemap makes it tempting to write:
lastModified: new Date()
That is convenient and usually wrong. It tells crawlers that every page changed
whenever the sitemap was generated, even when the only event was a deployment.
The
Sitemaps protocol
defines lastmod as the linked page's modification date, not the sitemap
generation date. Google goes further in its current
sitemap guidance:
it says the value is used when it is consistently and verifiably accurate and
should reflect a significant page update, such as main content, structured
data, or links.
The difficult part is not formatting an ISO timestamp. It is answering:
Which public routes did this source-code change significantly affect?
I traced and tested a Git-aware workflow for a Next.js site that answers that
question before touching any dates. It combines semantic metadata comparison,
an explicit route dependency map, an AST-based stamper, and a CI check that
fails when an affected route still has its previous timestamp.
It does not claim an SEO ranking improvement. Its goal is narrower: make the
sitemap metadata truthful and reviewable.
What I built or tested
The site persists lastModified beside each route definition. Its Next.js
sitemap handler simply emits those values:
export default function sitemap(): MetadataRoute.Sitemap {
return sitemapEntries.map((entry) => ({
url: `${siteConfig.siteUrl}${entry.path}`,
lastModified: entry.lastModified,
}));
}
A separate Node.js command has three modes:
npm run seo:lastmod:list
npm run seo:lastmod:stamp
npm run seo:lastmod:check
-
listshows routes affected by the current diff. -
stampreplaces only those routes' stored dates. -
checkfails when an affected existing route still has its previous date.
I ran the repository's integration test. It created a temporary Git repository,
committed a small route matrix, and exercised this sequence:
- change one tool definition;
- observe the tool route and homepage as affected;
- observe the pre-stamp check fail;
- stamp a fixed ISO date;
- observe the check pass;
- change one route-specific SEO record;
- observe only that route;
- change one static page;
- observe only its matching route.
The test passed one of one. It created and removed its own temporary repository;
the evidence repository's existing worktree state was identical before and
after.
Setup
The implementation uses Node.js, Git, TypeScript's compiler API, and Next.js
route metadata. It models four route families:
| Source definition | Route shape | Family |
|---|---|---|
| Tool definitions | /${slug} |
tool |
| Converter navigation | configured href
|
convert |
| Guide definitions | /guides/${slug} |
guide |
| Static sitemap entries | configured path
|
static |
Each descriptor states:
- the source file;
- the variable containing the records;
- the record's stable key;
- a function that converts the record into a public route; and
- the dependency family.
A reduced descriptor looks like this:
{
file: "lib/tool-config.ts",
variable: "toolDefinitions",
keyProperty: "slug",
routeFromValues: (values) => `/${values.slug}`,
family: "tool",
}
The workflow also needs a comparison base. With tracked working-tree changes,
it compares against HEAD. In a clean checkout, it uses HEAD^ when
available. CI can pass an explicit base branch:
npm run seo:lastmod:check -- --base origin/main
That distinction keeps local pre-commit checks and clean CI checkouts useful
without silently choosing the same reference in every environment.
Step-by-step walkthrough
1. Parse route metadata without executing the application
The script reads designated TypeScript source files and parses them with the
TypeScript compiler API. It locates a named variable initializer and expects an
array literal for route definitions.
Each object literal is serialized into a semantic value. A reduced
property-assignment-only version is:
function serializeSemanticNode(node, sourceFile) {
if (ts.isStringLiteral(node)) return node.text;
if (ts.isObjectLiteralExpression(node)) {
return Object.fromEntries(
node.properties
.filter((property) => {
return getPropertyName(property.name, sourceFile) !== "lastModified";
})
.map((property) => [
getPropertyName(property.name, sourceFile),
serializeSemanticNode(property.initializer, sourceFile),
]),
);
}
// Arrays, numbers, booleans, identifiers, and fallback source text...
}
The important line excludes lastModified from semantic comparison. Otherwise
stamping a timestamp would itself look like another route-content change,
creating a self-perpetuating diff.
This is more robust than a regular expression because it works with typed
object syntax, as const, satisfies, parentheses, and nested arrays or
objects. It is deliberately less general than executing the module. The
designated metadata variables must remain statically inspectable array or
object literals.
2. Compare route records against Git
The tool reads current sources from disk and base sources with:
git show <base>:<file>
It also collects changed paths from the tracked diff and untracked files. A
record is affected when it is new or when its semantic serialization differs
from the matching base record.
For a direct tool-definition change, the mapping adds:
- the tool route; and
-
/, because the homepage consumes the tool list.
A converter-definition change follows the same rule. A guide record maps to
its guide route. A static route record maps to its configured path.
This is not based only on filenames. Two records can live in one configuration
file while only one public route changes. Comparing records preserves that
granularity.
3. Propagate shared dependencies explicitly
Route records are not the whole page. A page can change because a component it
uses changed.
The script therefore maintains dependency sets:
const familyDependencyFiles = {
tool: new Set([
"app/[tool]/page.tsx",
"components/image-tool-workspace.tsx",
"components/related-tool-link.tsx",
]),
convert: new Set([
"app/convert/[format]/page.tsx",
"components/convert-workspace.tsx",
]),
guide: new Set([
"app/guides/[guide]/page.tsx",
"components/guide-page-shell.tsx",
]),
};
When one of those files changes significantly, every route in that family is
affected. Site-wide components such as the header, footer, or structured-data
component can mark all routes.
Route-specific SEO content gets a narrower treatment. The tool parses the SEO
content object and compares records by slug. Changing one record affects only
its matching tool or converter. Changing code outside that object in the same
component can affect the full tool and converter families.
Static pages map directly from a route to their app/.../page.tsx file.
The resulting policy is:
| Change | Affected routes |
|---|---|
| One tool record | Tool route and homepage |
| One converter record | Converter route and homepage |
| One guide record | Guide route |
| One route-specific SEO record | Matching tool or converter |
| Shared family component | Every route in that family |
| Header, footer, or shared structured data | All sitemap routes |
| One static page | Matching static route |
4. Validate before writing
The command first rejects duplicate routes and any route with a missing or
invalid lastModified. New pages therefore cannot enter the sitemap without a
valid initial date.
For existing affected routes, check compares the current date with the base
date:
const staleRoutes = affectedRoutes.filter((route) => {
const current = currentMetadata.routeRecords.get(route);
const previous = baseMetadata.routeRecords.get(route);
return previous && current.lastModified === previous.lastModified;
});
if (staleRoutes.length) {
throw new Error(
`${staleRoutes.length} affected route(s) still use the previous date`,
);
}
This is the real enforcement boundary. Route detection alone is only a report;
a non-zero check makes stale metadata visible to local hooks or CI.
5. Stamp only selected AST ranges
For every affected route, the parser retains the AST node for the existing date
initializer. The stamper groups replacements by source file and applies them
from the end of the file toward the beginning:
for (const replacement of replacements.sort(
(left, right) => right.start - left.start,
)) {
source =
source.slice(0, replacement.start) +
JSON.stringify(date) +
source.slice(replacement.end);
}
Reverse-order edits prevent an earlier replacement from shifting the offsets
of later nodes.
--dry-run computes and reports the same route set without writing. --route
adds an explicit route, while --all is reserved for a genuine site-wide
significant change.
The complete decision flow is:
Semantic record changes and explicit dependencies converge on one reviewable
route set.
What went wrong
The integration test intentionally started with a real failure.
It changed one tool description but left all sitemap dates untouched. The route
detector returned:
/
/compress-jpg
The checker then exited with status 1 because both affected routes still used
their previous dates. That failure is desirable: the tool page changed
directly, and the homepage changed indirectly because it consumes the tool
list.
After stamping both routes with a fixed timestamp, the same check passed. The
test then changed only the compress-jpg SEO record and correctly returned one
route rather than the whole tool family.
The experiment also exposed the central limitation: the dependency map is
policy, not omniscience. If a developer adds a significant shared component
outside the known sets, the script cannot infer that architectural edge. A
false negative is possible until the map is updated or the developer supplies
an explicit route.
There is a second boundary. The AST reader expects known variables to use
literal arrays or objects. Refactoring a route definition into a database call,
factory function, or generated import would break this inspection strategy
even if the runtime sitemap still worked.
Fix or mitigation
Treat the tool as an enforced workflow, not a magical detector.
Use list, stamp, check
For a normal page change:
npm run seo:lastmod:list
npm run seo:lastmod:stamp
npm run seo:lastmod:check
Review the list before stamping. The affected set is a content decision, not
just a code result.
For a significant dependency outside the map:
npm run seo:lastmod:stamp -- \
--route /compress-jpg \
--route /jpg-to-png
Use --all only when every indexable page truly received a significant
update.
Keep the dependency map beside architecture changes
When adding a shared route shell or moving content between components:
- update the route dependency map;
- add an isolated-Git test case for the new edge;
- run
listagainst the intended base; - inspect false positives and false negatives; and
- stamp only after the mapping is correct.
Formatting, dependency upgrades, analytics wiring, CSS-only changes, and
copyright-year changes should not automatically move every timestamp.
Test the failure before the success
The integration test is reusable:
- create a temporary Git repository;
- write and commit minimal route metadata;
- make one semantic content change;
- assert the exact affected route list;
- assert that
checkfails; - stamp a deterministic date;
- assert the edited metadata; and
- assert that
checknow passes.
Testing the pre-stamp failure proves the checker can reject stale metadata. A
test that runs only after stamping could pass even if enforcement were missing.
Trade-offs
- Persisted dates create reviewable diffs, but developers must maintain them.
- Record-level AST comparison is more precise than file-level matching, but it constrains how route metadata is authored.
- An explicit dependency map is understandable and testable, but incomplete until humans add every meaningful shared edge.
- Family-wide propagation avoids false negatives for shared components, but can update more routes than a deeper component analysis would.
- Git makes local and CI comparisons reproducible, but shallow CI clones need enough history for the chosen base.
- A route date is evidence of a significant content change, not a promise that a crawler will revisit the page or that rankings will change.
How I verified it
I used five evidence layers:
-
Protocol check: confirmed that
lastmodrepresents the linked page's modification date, not sitemap generation time. - Current Google guidance: confirmed the value should be consistently accurate and tied to significant page updates.
- Source trace: followed descriptors, AST serialization, Git diff collection, dependency propagation, validation, range edits, and final sitemap emission.
- Isolated integration test: ran the real command against a temporary Git repository and observed the expected pre-stamp failure, targeted writes, and successful post-stamp check.
- Release gates: ran article validation, Mermaid rendering, publisher dry-run, TypeScript checking, and the publisher repository's complete test suite before the authorized public write.
The focused sitemap test passed one test with zero failures. Running list
against the evidence repository's current HEAD returned no affected routes
for its unrelated working-tree files, and the repository status was unchanged
after the experiment.
Conclusion
Accurate sitemap dates require a route-impact model, not a clock in the sitemap
generator.
Persist each route's date, compare semantic route records against Git, propagate
significant shared dependencies explicitly, and fail CI when an affected route
still has its previous timestamp. Use AST ranges to update only the selected
fields and isolated Git fixtures to test both the failure and success paths.
The design will never infer every architectural dependency automatically. That
is acceptable when the dependency map is visible, reviewable, and backed by
manual route overrides.
The result is a sitemap that changes when pages change—not merely when the site
builds.
AI assistance disclosure
AI assisted with outlining and drafting. All implementation claims were
checked against the evidence repository, the route-impact behavior was
verified with its isolated Git integration test, and external behavior claims
were checked against primary sitemap documentation before publication.

Top comments (0)