---
title: Sitemap lastmod should track content, not deployments
description: Set accurate sitemap lastmod dates in Next.js using stored content timestamps, targeted revalidation, and checks that distinguish content changes from builds.
url: "https://vercel.com/kb/guide/correct-lastmod-nextjs-sitemap"
published: 2026-10-22
last_updated: 2026-10-22
authors: Forward Deployed Engineering Team, Andrew Jones
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Set each sitemap URL's `lastmod` to the date of its last significant published change. In Next.js, map a stored content timestamp to `lastModified` and omit it when no reliable date is available.

Updating every date on each build makes unchanged pages appear new. Accurate dates help crawlers prioritize revisits, while caching and targeted revalidation reduce the work needed to serve sitemap requests.

## What repeated sitemap checks revealed

We sampled public commerce and marketing sitemaps, then checked them again roughly 20 hours later. In one catalog, more than 8,000 product URLs shared a timestamp, and they all advanced together between captures. On another site, hundreds of marketing URLs moved together to the next calendar date.

Synchronized timestamps warrant a closer look at the generator and publishing history. Legitimate bulk updates can produce the same pattern, so compare the affected pages before concluding that their dates are wrong.

Google uses accurate `lastmod` values to help schedule revisits and may disregard dates that repeatedly misrepresent changes. Its [guidance on significant modifications](https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping#the-lastmod-element) includes changes to primary content, structured data, and links. Minor footer or sidebar edits do not require a new date.

_Missing_ `lastmod` is different, because the field is optional. If you can't determine a reliable content date, leaving it out is better than inventing one.

## A lesson from a large commerce engagement

In an engagement involving millions of product URLs, our Forward Deployed Engineering (FDE) team recommended setting `lastmod` dates based on what changed on each page.

For product pages, meaningful changes included displayed prices, availability, descriptions, images, and review information. For listing pages, they included changes to the products shown, descriptions, images, and filtering options.

The same reasoning applies to marketing sites. Update an article's date when its published content changes and leave it unchanged for internal workflow edits. Deployments that change significant content should also advance those dates.

## Implementing this on Vercel

These examples use Next.js 16 or later with [Cache Components](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents) enabled through `cacheComponents: true` in `next.config.ts`.

They assume an existing Postgres catalog and a configured `db.query` adapter exported from `@/lib/db` with these fields:

| Table        | Required fields                                                                       |
| ------------ | ------------------------------------------------------------------------------------- |
| `products`   | Unique, non-negative integer `id`; `slug`; `published`; nullable `content_updated_at` |
| `categories` | `slug`; nullable `content_updated_at`                                                 |

Adapt the SQL to your schema and use timezone-aware `content_updated_at` values.

If you retain the previous caching model, use [unstable\_cache](https://nextjs.org/docs/app/api-reference/functions/unstable_cache) for database queries. The `next.tags` option applies to `fetch` requests; it does not cache `db.query`.

### Keep the date with the content

Store a durable timestamp for the last _meaningful_ published change in your CMS or database. Use a dedicated field (the examples call it `content_updated_at`) if the existing `updatedAt` also changes for internal edits, imports, or background synchronization.

Map the stored timestamp to `lastModified` in `app/product/sitemap.ts` for the product routes used below. Sites with one sitemap can use `app/sitemap.ts`. Avoid assigning `new Date()` to every entry during generation.

For large catalogs, use [generateSitemaps](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps) to create multiple files. Each must stay within both [protocol limits](https://www.sitemaps.org/protocol.html): 50,000 URLs and 50 MB uncompressed. Check the generated XML size and use smaller partitions if needed.

Start with a cached data layer that tags each sitemap partition separately, so a single product change refreshes one partition instead of the whole catalog:

```typescript
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'

export const URLS_PER_SITEMAP = 50_000

// Partition by numeric ID so a product always lands in the same sitemap file.
export function partitionFor(productId: number) {
  return Math.floor(productId / URLS_PER_SITEMAP)
}

export async function getProductPartitionCount() {
  'use cache'
  cacheLife('max')
  cacheTag('sitemap:products:index')

  const { rows } = await db.query('SELECT MAX(id) AS max_id FROM products')
  return partitionFor(Number(rows[0].max_id ?? 0)) + 1
}

export async function getProductsForSitemap(partition: number) {
  'use cache'
  cacheLife('max')
  cacheTag(`sitemap:products:${partition}`)

  const start = partition * URLS_PER_SITEMAP
  const { rows } = await db.query(
    `SELECT slug, content_updated_at
       FROM products
      WHERE id >= $1 AND id < $2 AND published = true`,
    [start, start + URLS_PER_SITEMAP]
  )
  return rows as { slug: string; content_updated_at: Date | null }[]
}
```

The sitemap route then maps each stored date to `lastModified`. In Next.js 16 and later, the `id` prop is a promise that resolves to a string:

```typescript
import type { MetadataRoute } from 'next'
import {
  getProductPartitionCount,
  getProductsForSitemap,
} from '@/lib/catalog'

const BASE_URL = 'https://your_domain_here.com'

export async function generateSitemaps() {
  const count = await getProductPartitionCount()
  return Array.from({ length: count }, (_, id) => ({ id }))
}

export default async function sitemap(props: {
  id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
  const partition = Number(await props.id)
  const products = await getProductsForSitemap(partition)

  return products.map((product) => ({
    url: `${BASE_URL}/product/${product.slug}`,
    // Omit lastModified when there's no reliable content date.
    // Never fall back to the build time.
    ...(product.content_updated_at && {
      lastModified: product.content_updated_at,
    }),
  }))
}
```

Next.js serves partitions at `/product/sitemap/[id].xml`, such as `/product/sitemap/0.xml`. Entries without a reliable date omit `lastmod`.

List the partition URLs in `robots.txt` or submit them through Search Console. For many partitions, create a sitemap index and submit that index instead. Keep the discovery list current when new partitions appear; `generateSitemaps` does not create the index for you.

Use the same timestamp for `dateModified` in supported structured-data types, such as `Article` or `WebPage`. Keep any visible "last updated" date consistent with that value and the sitemap. Preserve the original publication date separately.

### Revalidate the affected content

Use an authenticated webhook to process published catalog changes. Commit the corresponding product and category changes to Postgres before this handler updates timestamps and invalidates caches. The example assumes that creates, updates, and deletions have already been synchronized.

Set `CATALOG_WEBHOOK_SECRET` in the server environment and adapt signature verification to your provider. Validate and normalize the provider's payload into the `CatalogEvent` shape, including the event type, product ID, changed fields, affected categories, and publication timestamp. Implement that adapter for your provider.

Call `revalidateTag(tag, 'max')` when briefly stale content is acceptable. The next request receives the cached version while fresh data loads in the background.

Use `revalidateTag(tag, { expire: 0 })` when the next request must wait for fresh data. Webhook Route Handlers use `revalidateTag` because `updateTag` is restricted to Server Actions. See the [revalidateTag API reference](https://nextjs.org/docs/app/api-reference/functions/revalidateTag) for the available options.

Match these field names to your normalized events and rendered content. Include all previous and current category slugs in `categorySlugs` so removed listings refresh too. If product pages display category membership, include that field in `PRODUCT_FIELDS` as well.

```typescript
import { createHmac, timingSafeEqual } from 'node:crypto'
import { revalidateTag } from 'next/cache'
import { db } from '@/lib/db'
import { partitionFor } from '@/lib/catalog'

// Fields that change what visitors see on the product page.
const PRODUCT_FIELDS = new Set([
  'title',
  'price',
  'availability',
  'description',
  'images',
  'reviews',
  'slug',
  'published',
])

// Fields that also appear on category listing pages.
const LISTING_FIELDS = new Set([
  'title',
  'price',
  'availability',
  'images',
  'slug',
  'published',
  'categorySlugs',
])

type CatalogEvent = {
  type: 'product.created' | 'product.updated' | 'product.deleted'
  productId: number
  changedFields: string[]
  categorySlugs: string[] // Union of categories before and after the change
  occurredAt: string // ISO 8601 timestamp of the published change
}

// Header name and signing scheme vary by provider. Check your platform's docs.
function isValidSignature(body: string, signature: string | null) {
  if (!signature) return false
  const expected = createHmac('sha256', process.env.CATALOG_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex')
  const received = Buffer.from(signature)
  const computed = Buffer.from(expected)
  return (
    received.length === computed.length && timingSafeEqual(received, computed)
  )
}

export async function POST(request: Request) {
  const body = await request.text()
  if (!isValidSignature(body, request.headers.get('x-webhook-signature'))) {
    return new Response('Invalid signature', { status: 401 })
  }

  const event = JSON.parse(body) as CatalogEvent
  const isStructural = event.type !== 'product.updated'
  const changesProduct =
    isStructural || event.changedFields.some((f) => PRODUCT_FIELDS.has(f))
  const changesListings =
    isStructural || event.changedFields.some((f) => LISTING_FIELDS.has(f))

  // Internal-only edits don't change any page, so leave every date alone.
  if (!changesProduct && !changesListings) {
    return Response.json({ revalidated: [] })
  }

  const tags: string[] = []

  if (changesProduct) {
    tags.push(
      `product:${event.productId}`,
      `sitemap:products:${partitionFor(event.productId)}`
    )

    // Repeated or older events cannot move this timestamp backward.
    if (event.type !== 'product.deleted') {
      await db.query(
        `UPDATE products
            SET content_updated_at = GREATEST(content_updated_at, $2)
          WHERE id = $1`,
        [event.productId, event.occurredAt]
      )
    }
  }

  if (changesListings && event.categorySlugs.length > 0) {
    await db.query(
      `UPDATE categories
          SET content_updated_at = GREATEST(content_updated_at, $2)
        WHERE slug = ANY($1)`,
      [event.categorySlugs, event.occurredAt]
    )
    tags.push(
      ...event.categorySlugs.map((slug) => `category:${slug}`),
      'sitemap:categories'
    )
  }

  if (event.type === 'product.created') {
    tags.push('sitemap:products:index')
  }

  for (const tag of tags) {
    revalidateTag(tag, 'max')
  }

  return Response.json({ revalidated: tags })
}
```

Tag product page data with `product:${id}`, category listing data with `category:${slug}`, and the category sitemap with `sitemap:categories`. Use the same `cacheTag` pattern as `lib/catalog.ts`.

When a displayed price changes, advance the product date and the dates of category pages that show that price. Internal-only edits leave the dates unchanged.

### Cache sitemap responses

With Cache Components enabled, the cached catalog queries in this example let Next.js prerender the sitemap response. Their `cacheLife` settings control revalidation, and `cacheTag` lets the webhook invalidate the affected data.

For a custom XML response outside the metadata-route implementation, configure [CDN caching](https://vercel.com/docs/caching/cache-control-headers) with an appropriate `s-maxage`. Add `stale-while-revalidate` only when serving the previous response during a refresh is acceptable.

Choose the cache duration based on how quickly published changes need to appear. Cache expiration controls when the overall response refreshes; it should not become the modification date of every URL inside it.

If your source can't send webhooks, use [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs/manage-cron-jobs) for periodic reconciliation against the Production deployment. Compare source changes and preserve dates for unchanged pages.

Set `CRON_SECRET` and validate the `Authorization` header against `Bearer ${process.env.CRON_SECRET}` before processing. Make repeated invocations safe.

## Verify the signal and the effect separately

Test the sitemap against two controlled changes:

1. Capture it, then deploy without changing page content or source records. Compare entries by URL to confirm that `lastmod` values are unchanged.
   
2. Publish a meaningful change to one product. After the webhook and cache refresh complete, compare again. Only that product and listing pages that display the changed content should receive new dates.
   

With `revalidateTag(tag, 'max')`, the first request may return the previous XML while refreshing it. Fetch again after the refresh before judging the result. If either test still fails, check stored dates, webhook field selection, and cache invalidation.

Use [Search Console's Crawl Stats report](https://support.google.com/webmasters/answer/9679690) to monitor aggregate requests, response times, and serving errors. For a comparison of changed and unchanged URLs, use request logs that include traffic served from the CDN as well as the origin.

Compare similar periods before and after the fix, accounting for changes in publishing volume and site traffic. Accurate dates help Google schedule revisits, but they do not guarantee fewer requests or faster indexing. Measure origin work separately to assess the effect of caching and revalidation.

## Audit your own sitemap with an agent

Paste the prompt below into an AI coding agent such as [fx](https://github.com/vercel-labs/fx), Claude Code, or Codex, and replace `<DOMAIN>` with your domain. It compares sitemap snapshots and sampled pages to flag dates that need investigation.

### Agent prompt

```txt
Audit the sitemap for <DOMAIN> and report evidence about whether its lastmod values reflect significant content changes. If <DOMAIN> is still a placeholder, ask for the real domain before proceeding.

1. Fetch /robots.txt and follow its Sitemap entries, falling back to /sitemap.xml when none exist. Handle gzip and nested indexes. If an index lists more than 50 child sitemaps, sample 20 across the index. Record every fetched child URL and any fetch failures. On follow-up runs, reuse the recorded set and report additions or missing files separately.

2. Parse the URL entries in the fetched files. Record loc, raw lastmod, and the date's precision and timezone where supplied. Deduplicate URLs for counts and report conflicting dates. State whether counts describe the complete inventory or a sample.

3. Report missing or invalid dates, the 10 most common lastmod values, and their shares of the fetched URLs. Treat shared dates and clustered times as investigation leads. Date-only values, legitimate bulk updates, and older publication histories can explain these patterns. Do not classify a date before 2000 or a value shared by more than 20% of URLs as invalid solely for that reason. Check future dates with timezone and clock differences in mind.

4. Choose up to 10 URLs spread across the date distribution and reuse them on follow-up runs. Record HTTP status, redirects, and whether page content was available. Compare lastmod with visible update dates, supported dateModified markup, and Last-Modified headers. Keep publication dates separate and compare only compatible precision and meaning. Disagreement is a lead to investigate.

5. Define a repeatable extraction method for each page type. Hash its main content while preserving meaningful link destinations and image attributes. Extract and compare relevant JSON-LD separately before removing other scripts. Record any content unavailable without JavaScript and any unstable or personalized elements excluded from comparison. Do not interpret fetch failures as unchanged content.

6. Save a timestamped JSON snapshot containing the domain, capture time, fetched sitemap URLs, sampling rules, parsed entries, sample-page results, extraction method, and hashes. Compare only with an earlier snapshot of the same domain and method. Match entries by loc and report additions, removals, and changed dates. Distinguish changes to main content from changes to structured data.

7. On the first run, recommend another capture in about 24 hours. On a follow-up run, state what the evidence supports and what remains uncertain. Shared timestamp changes with unchanged sample hashes justify inspecting the generator and publication history; they do not prove that the entire site stayed unchanged. Identify the generator logic or publishing records needed to confirm the cause.
```

Run the audit again after about 24 hours. If many dates advance together while sampled content and structured data stay unchanged, inspect the timestamp assignment and publishing history. Confirm that the generator uses build time before applying that diagnosis to the wider catalog.

## Next steps

- Read the [Next.js sitemap reference](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap) for supported fields.
  
- Split large catalogs with [`generateSitemaps`](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps).
  
- Learn how [`cacheTag`](https://nextjs.org/docs/app/api-reference/functions/cacheTag) and [`revalidateTag`](https://nextjs.org/docs/app/api-reference/functions/revalidateTag) work together
  
- Set up periodic reconciliation with [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs/manage-cron-jobs).
  
- Review [Google's guidance on sitemaps and lastmod](https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping).

## More Next.js guides

- [Next.js on Vercel vs Webflow Cloud](/kb/guide/next-js-on-vercel-vs-webflow-cloud): Compare running Next.js on Vercel Functions with Fluid compute against Webflow Cloud on Cloudflare Workers. Learn how Next.js feature support, runtimes, storage, pricing, and developer experience differ so you can choose the right platform.
- [Migrate a Next.js app from Webflow Cloud to Vercel](/kb/guide/migrate-a-next-js-app-from-webflow-cloud-to-vercel): Move your Next.js app from Webflow Cloud to Vercel: remove the OpenNext Cloudflare adapter, drop the base path, map storage bindings to Vercel Blob, Redis, and Postgres, and deploy with Git or the vercel CLI.
- [Vercel vs Webflow Cloud](/kb/guide/vercel-vs-webflow-cloud): Compare Vercel and Webflow Cloud for deploying Next.js and Astro apps, including runtime, framework support, storage, pricing, and where your code runs.