A CMS publish event does not prove that a page reached your Gatsby sitemap. Check three inventories: the content that should be public, the routes Gatsby actually created, and the URLs in the deployed XML. Differences between them usually identify the part of the publishing pipeline that needs attention.
These seven Gatsby sitemap checks focus on CMS-driven websites, where publication states, pagination, translated slugs and modification dates can diverge from the generated pages. They include a route-to-content configuration example and a script for checking sitemap coverage.
The examples follow the official documentation and published source of gatsby-plugin-sitemap 6.16.0, checked on November 8, 2026. They are documentation-based examples for a domain-root deployment; adapt them to your CMS schema and project configuration before use.
1. Confirm the production sitemap entry point
Start with the installed plugin version and the output of a successful production build. The official Gatsby sitemap guide says sitemap generation happens during the production build, so a missing XML file in gatsby develop is not enough to diagnose a failure.
With the current default configuration, inspect:
public/sitemap-index.xml
public/sitemap-0.xml
The index points to the child sitemap files. Larger inventories can produce more than one child; do not stop after opening the first one. Custom output settings can also change their location.
Check that siteMetadata.siteUrl or your explicit resolveSiteUrl returns the production origin. A preview deployment can successfully generate XML that still names the wrong host. Conversely, testing an assumed /sitemap.xml address can make a healthy /sitemap-index.xml look missing.
Record the installed version, build identity and actual index URL. Then inspect the domain's robots.txt and verify that any Sitemap: declaration points to that address. This gives the rest of the audit a real artifact to check.
2. Compare eligible CMS records with generated routes
Build the expected inventory from publishing rules before looking at the sitemap. Export every CMS record that should currently have a public, indexable page. Follow all API pagination, exclude drafts and future publications, and resolve each record to its final route, including its locale and translated slug.
Add eligible pages that do not come from the CMS, such as the homepage and a public contact page. A content-only export would otherwise remove legitimate routes from a strict allowlist.
Compare this inventory with Gatsby's allSitePage.nodes:
| Difference | Likely investigation |
|---|---|
| An eligible CMS record has no generated route | Source-query pagination, publication filters or page-creation logic |
| A generated route has no eligible inventory entry | Missing non-CMS entry, preview page or deliberately excluded route |
| Two records resolve to one path | Slug collision, locale mapping or canonical policy |
| One record generates several paths | Intentional localized pages, aliases or duplicate routes |
Keep record IDs in your working export so you can trace a path back to its source. The configuration below only needs path and optional lastmod, but the underlying audit needs more than a list of slugs.
Do not build the expected inventory by copying URLs out of the sitemap you are checking. Both lists would inherit the same omissions. Likewise, an API failure must not silently reuse yesterday's export or replace the inventory with an empty array. Make export completion and the source revision visible in the build workflow.
3. Keep the query, resolver and serializer in agreement
The current plugin documentation separates three jobs: query retrieves data, resolvePages returns an array of page objects, and serialize converts each page into a sitemap entry. An older tutorial that returns an entire array from serialize can describe a different API.
Here is a CMS-neutral example. Save the following as sitemap-records.json beside gatsby-config.js. These two illustrative records must be replaced by the complete inventory from check two:
[
{
"path": "/",
"lastmod": null
},
{
"path": "/guides/deployment/",
"lastmod": "2026-10-20T09:30:00Z"
}
]
For a domain-root deployment using a CommonJS Gatsby configuration, use this structure. Merge the sitemap entry and helpers into your existing configuration, preserving your other plugins and site metadata. Replace https://example.com with your actual production origin. If your project uses ESM, express the same helpers and plugin options in its existing module format.
const records = require("./sitemap-records.json");
const siteUrl = "https://example.com";
if (!Array.isArray(records) || records.length === 0) {
throw new Error("Expected a complete, nonempty sitemap inventory.");
}
const wanted = new Map();
for (const record of records) {
const path = record.path;
if (
typeof path !== "string" ||
!path.startsWith("/") ||
path.startsWith("//") ||
/[?#\\]/.test(path) ||
path.split("/").some((part) => part === "." || part === "..")
) {
throw new Error("Inventory contains an invalid route path.");
}
if (wanted.has(path)) {
throw new Error(`Duplicate inventory path: ${path}`);
}
wanted.set(path, record);
}
module.exports = {
siteMetadata: { siteUrl },
plugins: [
{
resolve: "gatsby-plugin-sitemap",
options: {
query: `{ allSitePage { nodes { path } } }`,
resolveSiteUrl: () => siteUrl,
resolvePages: ({ allSitePage }) => {
const generated = new Map(
allSitePage.nodes.map((page) => [page.path, page])
);
return [...wanted].map(([path, record]) => {
const page = generated.get(path);
if (!page) {
throw new Error(`No Gatsby page for inventory path: ${path}`);
}
return { ...page, lastmod: record.lastmod };
});
},
serialize: ({ path, lastmod }) => ({
url: path,
...(lastmod ? { lastmod } : {}),
}),
},
},
],
};
This joins expected paths to actual Gatsby pages and preserves the path field. A missing route or duplicate inventory path stops the build instead of producing a plausible but incomplete sitemap. Generated routes outside the allowlist are deliberately omitted.
The example keeps the plugin's built-in exclusions for development, error and offline fallback pages. Do not add those routes to the inventory. If you introduce a custom filterPages, note that returning true means exclude.
This is an inventory contract, not a CMS exporter: connect your real publication fields, pagination and path mapping upstream. Runtime-only route variants also need an explicit, verified URL inventory; a route pattern does not enumerate its individual pages.
For a site under a subdirectory, first reconcile your installed version's pathPrefix/basePath behavior with final public URLs. The domain-root example does not establish that mapping for you.
4. Preserve meaningful CMS modification dates
A CMS updated_at field is useful only if it represents a significant change to the rendered page. Workflow edits, internal notes and automated saves may update a record without changing what visitors or crawlers see.
Google's sitemap guidance says it uses lastmod when the value is consistently and verifiably accurate. Significant changes can include main content, structured data or important links. A copyright-year change is not a reason to refresh every page's date.
Use a source timestamp tied to those changes. Validate its date and timezone during export, and preserve the correct instant. If you only know an actual calendar date, the sitemap format also permits a date without a time. When no trustworthy modification date exists, omit lastmod; do not substitute the current build time.
The example's null homepage date is intentional. Its serializer omits that field. The sample guide timestamp is illustrative, not evidence of a real content update.
Derived pages need their own rule. A category page can change when an article is added, removed or retitled, even if its template file stays untouched. Capture that rendered-content change rather than using the last Git commit to the template.
Finally, Google ignores priority and changefreq. The custom serializer leaves them out so the audit can focus on correct URLs and defensible dates.
5. Check the final URLs against page indexing signals
Inspect the absolute <loc> values in the generated XML. Matching a route string is not enough if the plugin adds an unexpected hostname or path prefix.
For representative CMS pages, fetch the listed URL and compare its final response with the page's canonical and indexing directives:
| Check | Evidence that needs correction |
|---|---|
| Production origin | Preview hostname, HTTP variant or unintended alternate host |
| Prefix and trailing slash | Doubled subdirectory, missing prefix or redirecting slash variant |
| Localized route | Untranslated slug, wrong locale prefix or alias listed as the preferred page |
| HTTP response | Redirect, missing page, server error or error content served with status 200 |
| Indexing signals | noindex, crawl restriction or a canonical pointing elsewhere |
Choose the preferred public URL first, then align the CMS mapping, internal links, canonical and sitemap entry. Avoid silently normalizing paths during the audit: a slash or case difference can be the evidence that identifies the defect.
For localized content, map the real translated route rather than constructing it from a shared record ID. Only list versions that actually exist and are intended to be indexed. A complete hreflang implementation is a separate task; changing sitemap membership alone does not implement it.
Removing a URL from a sitemap does not prevent its crawling or indexing. Our XML sitemap guide explains the wider inclusion rules, while the Gatsby SEO checks cover page-level metadata and response behavior.
6. Audit every child XML file against the expected set
A valid XML response can still contain the wrong inventory. Check every child referenced by the index, including duplicates across files. Google's limits are 50,000 URLs and 50MB uncompressed per sitemap; the plugin's current default split threshold is 45,000 entries.
Save the following as audit-sitemap.py. It uses Python 3.9 or later and the standard library, with no additional packages. This example checks a domain-root build whose sitemap files are directly inside public/.
import sys
from collections import Counter
from pathlib import Path
from urllib.parse import unquote, urlsplit
import xml.etree.ElementTree as ET
entry = Path(sys.argv[1]).resolve()
origin = urlsplit(sys.argv[2])
expected_file = Path(sys.argv[3])
if (
origin.scheme != "https" or not origin.netloc
or origin.path not in ("", "/") or origin.query or origin.fragment
):
raise SystemExit("Supply a domain-root HTTPS origin.")
root_dir = entry.parent
ns = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
tag = lambda name: "{" + ns["s"] + "}" + name
max_bytes = 50 * 1024 * 1024
def read_xml(path):
data = path.read_bytes()
if len(data) > max_bytes:
raise ValueError(f"XML exceeds 50MB: {path}")
return ET.fromstring(data.decode("utf-8-sig"))
def local_child(address):
url = urlsplit(address)
if (
(url.scheme, url.netloc) != (origin.scheme, origin.netloc)
or url.query or url.fragment
):
raise ValueError(f"Unexpected child sitemap URL: {address}")
path = (root_dir / unquote(url.path).lstrip("/")).resolve()
if not path.is_relative_to(root_dir):
raise ValueError("Child sitemap escapes the build directory.")
return path
root = read_xml(entry)
if root.tag == tag("sitemapindex"):
children = [
(node.findtext("s:loc", default="", namespaces=ns)).strip()
for node in root.findall("s:sitemap", ns)
]
if not children or len(children) != len(set(children)):
raise ValueError("Empty index or duplicate child references.")
files = [local_child(address) for address in children]
elif root.tag == tag("urlset"):
files = [entry]
else:
raise ValueError("Expected sitemapindex or urlset namespace.")
actual = []
for file in files:
document = read_xml(file)
if document.tag != tag("urlset"):
raise ValueError(f"Expected a URL sitemap: {file}")
urls = [
(node.findtext("s:loc", default="", namespaces=ns)).strip()
for node in document.findall("s:url", ns)
]
if len(urls) > 50000:
raise ValueError(f"Too many URLs: {file}")
for address in urls:
url = urlsplit(address)
if (
(url.scheme, url.netloc) != (origin.scheme, origin.netloc)
or not url.path or url.fragment
):
raise ValueError(f"Unexpected page URL: {address}")
actual.extend(urls)
expected_rows = [
line.strip() for line in expected_file.read_text(encoding="utf-8").splitlines()
if line.strip()
]
expected = set(expected_rows)
if not expected or len(expected) != len(expected_rows):
raise ValueError("Expected inventory is empty or contains duplicates.")
counts = Counter(actual)
duplicates = sorted(url for url, count in counts.items() if count > 1)
missing = sorted(expected - set(actual))
unexpected = sorted(set(actual) - expected)
for label, values in [
("Duplicate", duplicates), ("Missing", missing), ("Unexpected", unexpected)
]:
print(f"{label}: {len(values)}")
for value in values[:20]:
print(" ", value)
print(f"Checked {len(files)} sitemap file(s), {len(actual)} URL entries.")
raise SystemExit(1 if duplicates or missing or unexpected else 0)
Export expected-urls.txt from the complete eligible inventory, using final absolute URLs, one per line. For the two illustrative records above, that file would be:
https://example.com/
https://example.com/guides/deployment/
After a successful Gatsby build, run:
python audit-sitemap.py public/sitemap-index.xml https://example.com/ expected-urls.txt
A missing child file, malformed XML, unexpected origin or oversized file raises an error. Duplicate, missing or unexpected page URLs produce a nonzero exit status. The output shows the first 20 URLs in each mismatch category, along with the full count.
Keep the script and expected inventory outside public/ unless you intentionally want to publish them. If you customize the sitemap output directory, adapt the local child-path mapping; this example assumes the root-level output established in check one.
This is a local inventory and structure check. It does not fetch pages, validate lastmod semantics, test canonical tags or establish that Google has indexed anything. Those require separate evidence.
7. Trace CMS changes through the deployed release
Follow a known content change from the CMS to the public website. For a statically generated Gatsby site, the chain usually includes the CMS event, a fresh export, page creation, sitemap generation and deployment. Check each stage before blaming the sitemap plugin.
Use changes with clear expected outcomes:
| CMS change | Evidence to verify after the release |
|---|---|
| Publish a new eligible article | Its generated route, public page and child-sitemap entry all exist |
| Change a slug | The new canonical URL is listed; the retired URL is handled according to your redirect policy |
| Unpublish an article | Its sitemap entry is removed and the public response follows your removal policy |
| Make a significant content update | The new content is visible and its source-derived lastmod is accurate |
Do not perform these changes on production merely to test a sitemap. Use an already authorized content change or an appropriate local/staging workflow.
Gatsby supports different rendering options, including SSR and DSG. A page's runtime data refresh does not itself rewrite the static XML generated by this plugin's build hook. Verify how your own workflow refreshes both artifacts.
After deployment, fetch the actual public index and its referenced children. Confirm a known new URL, a known removal when applicable, and the relevant modification date. Fetch the target page separately to verify its content and indexing signals. Compare release identity and cache headers when the build artifact is correct but public XML is stale.
Recheck the robots.txt sitemap reference and a useful incoming internal link to the page. A working page, a current sitemap and a crawlable internal link are separate pieces of discovery evidence.
Keep a short record of the CMS revision, build/release identity, expected URL changes and public readback. The audit is complete when those observed artifacts agree. A clean sitemap helps discovery; it does not guarantee crawling, indexing or rankings.