DEV Community

reflexdev
reflexdev

Posted on

How to check a Shopify sitemap for broken links and 404s

Shopify builds a sitemap for every store at /sitemap.xml and keeps it up to date. You can't edit the file. That is fine until a product is deleted, a collection is unpublished, or a link in a product description still points at a page that no longer exists.

Shopify does not create a redirect when you delete a product. The old URL starts returning 404 until you add one yourself, under Content > Menus > View URL redirects in the admin. Google Search Console will show some of those 404s, but only after Google happens to recrawl them.

This post covers three ways to check the sitemap yourself, including the links on those pages, and what the output looks like from a real run.

What Shopify actually puts in /sitemap.xml

Open https://your-store.com/sitemap.xml and you will not see a list of products. You will see a sitemap index: a list of other sitemaps. On Shopify's official Dawn theme demo store, which I used for the run below, the index had 11 children:

  • sitemap_products_1.xml, sitemap_pages_1.xml, sitemap_collections_1.xml, sitemap_blogs_1.xml and sitemap_articles_1.xml
  • sitemap_agentic_discovery.xml, which listed a single URL, /agents.md. That file is for AI shopping agents, not for shoppers
  • the same set again under /en-fr/, because the demo store has a French-market locale

The product, page and collection children carry a query string such as ?from=6920720777305&to=6920737783897. Those query strings change as you add or remove items, and a child requested without one can return 400. The only stable address is the index itself. Submit /sitemap.xml to Google Search Console, not the child files.

I counted 124 page URLs in that demo index: 62 on the default locale (the homepage, 32 products, 10 pages, 14 collections, 1 blog index, 3 articles, and /agents.md) and the same 62 again under /en-fr/. A live catalogue will be much larger, and once a product child hits 50,000 URLs Shopify starts a sitemap_products_2.xml.

Filtered collection views (?sort_by=, ?filter=) are not in the sitemap. Shopify's robots.txt also disallows many of those filter and sort URLs, along with /admin, /checkout, /cart/ and /account (it allows /account/login).

Option 1: curl in a loop (free, local)

A script that only fetches /sitemap.xml will print the child sitemap URLs, not the products. You have to walk the index first, and decode & in those query strings:

index=https://www.example.com/sitemap.xml
curl -sL "$index" | grep -o '<loc>[^<]*' | sed 's/<loc>//' | while read -r child; do
  child=$(printf '%s' "$child" | sed 's/&amp;/\&/g')
  curl -sL "$child" | grep -o '<loc>[^<]*' | sed 's/<loc>//'
done | while read -r url; do
  curl -s -o /dev/null --max-time 15 \
    -w "%{http_code} %{num_redirects} %{url_effective} $url\n" "$url"
  sleep 0.5
done
Enter fullscreen mode Exit fullscreen mode

Each line prints the status code, how many redirects were followed, where the URL ended up, and the original URL. Pipe it into grep -v '^200 0' to see only the URLs that need attention.

This works for a one-off check of a small store. The catches:

  • You only see the final hop, not the URLs in between.
  • It does not tell you about noindex or canonical tags.
  • It only checks the pages in the sitemap, not the links inside product descriptions, menus or blog posts, which is where a lot of Shopify 404s actually live.
  • Keep the sleep in, even on your own store. Bursting hundreds of requests at once can trigger rate limiting.

Option 2: Google Search Console, or a desktop crawler

Two tools you may already have:

  • Google Search Console, under Page indexing, reports URLs that returned 404 or 410 when Google crawled them. It is free and it is the record of what Google saw, but it is not a complete check of the sitemap, and it lags.
  • Screaming Frog SEO Spider can crawl in list mode from a sitemap index and reports status codes, redirects, canonicals and directives. The free version is limited to 500 URLs per crawl.

Screaming Frog runs on your own machine, so it is fast and, up to 500 URLs, free. The trade-off is that someone has to remember to run it.

When you do find a 404, fix it in the Shopify admin: Content > Menus > View URL redirects. Shopify only honours a redirect when the old path already returns 404, so this is for deleted or unpublished URLs, not for a live product you would rather send somewhere else. If you change a product handle, leave the "Create a URL redirect" checkbox ticked; that case is handled for you.

Option 3: a hosted sitemap link checker

If you want a spreadsheet of results, or a scheduled check you don't have to babysit, you can run it in the cloud. Full disclosure: I built a tool for this on Apify, the Sitemap Link Checker. It costs US$0.50 per 1,000 URLs checked, so compare that with Options 1 and 2 before you pick it. The rest of this walkthrough uses it because that's where the sample output comes from.

What it does with a Shopify sitemap:

  • Reads the index and each child, including the query strings and any locale copies.
  • Records every redirect hop, not just the final one.
  • Checks noindex (the meta robots tag and the X-Robots-Tag header) and the canonical URL in the same request.
  • Can also check the <a href> links found on each page, one level deep, and tells you which page each broken link was found on.

It's plain HTTP, with no browser. It respects robots.txt, including Crawl-delay, sends at most 2 requests per second per site by default, and backs off when a site answers 429 or 503.

Step 1: Point it at your store's sitemap index

Open the Actor, click Try for free, and switch to the JSON input. This is the exact input I used for the sample below, against Shopify's Dawn demo store. robots.txt on that demo allows crawling of public product, collection, page and blog URLs.

{
  "sitemapUrls": ["https://theme-dawn-demo.myshopify.com/sitemap.xml"],
  "maxUrls": 40,
  "crawlLinks": true,
  "maxLinkUrls": 12,
  "sameDomainOnly": true
}
Enter fullscreen mode Exit fullscreen mode
  • sitemapUrls should be the index, https://your-store.com/sitemap.xml. You can also give the homepage under startUrls and leave discoverSitemaps on (the default), and it will read the Sitemap: line in robots.txt.
  • maxUrls caps how many sitemap URLs are checked. The default is 50. Raise it to at least the size of your catalogue. A low cap is not spread evenly across products, collections, pages and blogs: the Actor walks the index in order, so after /agents.md it will fill most of a small cap with product URLs and may never reach the later children.
  • crawlLinks also checks the links found on those pages. This is how you catch a broken link in a product description or the menu, not only a URL that is still listed in the sitemap.
  • maxLinkUrls caps those extra link checks, on top of maxUrls.
  • sameDomainOnly: true keeps the link crawl on your own store. Turn it off if you also want to check outbound links, for example from blog posts.

On the Dawn demo, that run read the index (11 children), then the agentic, products and pages sitemaps. It checked all 33 product sitemap URLs (the homepage plus 32 products), /agents.md, and 6 of the 10 pages, then stopped because it hit the cap of 40. It never reached the collections, blogs, articles or /en-fr/ children. The 12 on-page links it did check were taken from the homepage, and they included 10 collection URLs that the sitemap cap had missed, plus /cart and /account/login. The run finished in about 37 seconds.

For a full check of this demo store you would set maxUrls to 124 or more. For your own store, open the index, add up the child counts, and set the cap a bit above that.

Step 2: Read the output

You get one row per URL. This is a real row from that run, a product listed in the products sitemap:

{
  "url": "https://theme-dawn-demo.myshopify.com/products/thelma-sandal",
  "source": "sitemap",
  "foundOn": null,
  "sitemapUrl": "https://theme-dawn-demo.myshopify.com/sitemap_products_1.xml?from=6920720777305&to=6920737783897",
  "lastmod": "2026-11-11T20:17:25-06:00",
  "statusCode": 200,
  "finalStatusCode": 200,
  "statusClass": "2xx",
  "isBroken": false,
  "finalUrl": "https://theme-dawn-demo.myshopify.com/products/thelma-sandal",
  "redirectCount": 0,
  "redirectChain": [],
  "responseTimeMs": 184,
  "contentType": "text/html; charset=utf-8",
  "noindex": false,
  "canonical": "https://theme-dawn-demo.myshopify.com/products/thelma-sandal",
  "canonicalIsSelf": true,
  "linksFound": 26,
  "error": null
}
Enter fullscreen mode Exit fullscreen mode

The fields I'd look at first on a live store:

  • isBroken: true when the final status is 4xx or 5xx, or the request failed outright (DNS, timeout, TLS error, redirect loop). Filter on this first. A deleted product with no redirect shows up here as a 404.
  • statusCode vs finalStatusCode: the first response and the one after all redirects. A sitemap URL that 301s to a new handle should usually be left as a redirect in the admin, not expected to stay in the sitemap for long; Shopify updates the index on its own.
  • foundOn: for links found inside pages, the page that contains the link, so you know whether to edit a menu, a product description or a blog post.
  • noindex and canonicalIsSelf: a page that's in the sitemap but marked noindex, or whose canonical points somewhere else, is sending search engines mixed signals.

There's also a SUMMARY record in the run's key-value store. Here's the relevant part from the same run:

{
  "urlsChecked": 52,
  "bySource": { "sitemap": 40, "page": 12 },
  "statusCounts": { "2xx": 52, "3xx": 0, "4xx": 0, "5xx": 0, "error": 0 },
  "finalStatusCounts": { "2xx": 52, "3xx": 0, "4xx": 0, "5xx": 0, "error": 0 },
  "robotsBlocked": 0,
  "brokenCount": 0,
  "redirectedCount": 0,
  "redirectChainsOver1Hop": 0,
  "noindexCount": 0,
  "nonSelfCanonicalCount": 0,
  "avgResponseTimeMs": 526,
  "reachedMaxUrls": true,
  "reachedMaxLinkUrls": true
}
Enter fullscreen mode Exit fullscreen mode

There were no broken links, no redirects and no noindex pages in this sample. That's a normal result for Shopify's own demo store, and a clean report is what you want from a regular check. What the run still showed:

  • The index really is an index. The Actor recorded 11 children and then read the first three (sitemap_agentic_discovery.xml, the products sitemap with 33 URLs, and the pages sitemap with 10).
  • /agents.md returned 200 with contentType: text/markdown; charset=utf-8 and no canonical. It is meant for shopping agents, not for shoppers. You can ignore it when you filter the spreadsheet, but a small maxUrls will always spend one slot on it because it is first in the index.
  • Every product URL in the sample had the same lastmod of 11 November 2026. The pages had older, different dates. I would not treat product lastmod as a freshness signal.
  • Turning on the link crawl was what surfaced the collection URLs, because the sitemap cap stopped before sitemap_collections_1.xml. One of those rows looked like this:
{
  "url": "https://theme-dawn-demo.myshopify.com/collections/tote-bags",
  "source": "page",
  "foundOn": "https://theme-dawn-demo.myshopify.com/",
  "statusCode": 200,
  "finalStatusCode": 200,
  "isBroken": false,
  "canonical": "https://theme-dawn-demo.myshopify.com/collections/tote-bags",
  "canonicalIsSelf": true
}
Enter fullscreen mode Exit fullscreen mode

Export to CSV or Excel and filter isBroken = true to get a to-do list. Then add a URL redirect in the Shopify admin for each deleted path, or fix the link on the page named in foundOn.

Step 3: Cost

That run checked 52 URLs that returned a response. At $0.0005 per URL plus a $0.00005 start fee, that comes to about $0.026. Checking all 124 URLs in the Dawn demo index would be about $0.06. A 10,000-URL catalogue costs about $5, plus whatever you allow for on-page links. You can set Maximum cost per run in the run options, and the Actor stops cleanly when it reaches that limit.

Speed is set by the per-site rate limit. At the default 2 requests per second, 1,000 URLs on one store take a little over 8 minutes. If it's your own store, you can raise maxRequestsPerSecondPerHost.

Step 4: Automate it

The version that actually catches a deleted product is a scheduled one:

  1. Save your input as a task, with maxUrls high enough for the full catalogue.
  2. Add a schedule, for example weekly, or daily if you add and remove products a lot.
  3. Add a webhook or integration that sends you the SUMMARY record, and check brokenCount.

You can also call it from a script with Apify's API, using the normal asynchronous run endpoint for a large catalogue, then fail the job if brokenCount is above zero.

Caveats worth knowing (whichever option you pick)

  • No JavaScript rendering. Plain HTTP checkers, including curl and this Actor, only see links that are in the HTML the server sends. Links added by a theme or an app after the page loads need a browser-based crawler.
  • A 429 isn't a broken page. It means the site rate-limited you. Slow down and re-check those URLs rather than adding redirects for them.
  • /cart is not the same as /cart/. Shopify's robots.txt disallows /cart/ (with the trailing slash), /checkout and /account, and allows /account/login. A polite checker will skip the disallowed paths. In this run, the homepage linked to /cart and /account/login, both of which were allowed and returned 200.
  • Only check stores you own or have permission to test, and keep the request rate gentle on stores that aren't yours.

Wrapping up

For a quick check of a small catalogue, walking the Shopify index with the curl loop above is enough. For a deeper crawl on your own machine, Screaming Frog (up to 500 URLs free) is a good choice, and Google Search Console is worth watching in any case. When you want the index, the locale copies, redirect chains, noindex and broken in-content links in one spreadsheet, on a schedule, a hosted checker saves you the plumbing.

If you try the Actor and something doesn't work, or there's a Shopify-specific check you wish it did, open an issue on its Issues tab. I'd like to hear about it.

Top comments (1)

Collapse
 
elias_0549e0e2ef8cf6ad89a profile image
RAMO •

The point about submitting the index and not the child files with the ?from=&to= query strings is a good one, I hadn't realised those children can 400 without the query.

One thing I'd add to the curl loop: also flag 200s that are really "soft 404s". A common one on stores is a removed product that keeps returning 200 with an empty "not found" layout, so a status-code check passes it every time. Grepping the response for the product title, or for a noindex meta tag, catches those. Same with redirects: a 301 whose final URL is the homepage is usually a deleted product that someone redirected the lazy way, and it's worth listing separately from redirects that go to a real replacement page.