Shopify Migration SEO Checklist: Test Every 301

A Shopify migration SEO checklist starts with one test: request every old URL on the live store and confirm a single 301 to the mapped page, which returns 200 with a canonical pointing at itself. Then submit the sitemap, file a Change of Address only if the domain changed, and watch Search Console for weeks.

Most post-cutover problems show up in that first test, and none of them show up in the admin’s redirect list.

What should a Shopify migration SEO checklist cover after cutover?

Redirects, canonicals, the sitemap, Search Console and your own links, in that order.

Redirects lead because they are how Google moves an old page’s signals, links from other sites included, to the new URL. Its guide to site moves with URL changes asks for permanent server-side redirects (301 or 308), straight to the final destination, kept “generally at least 1 year”.

Once redirects are live, the same guide says to check that rel="canonical" on the new site uses the new URLs. Shopify gives themes the Liquid canonical_url object for this; if your theme or an app writes its own tag instead, check what it writes.

Shopify generates /sitemap.xml at the root of each domain and updates it as products, collections, pages and posts change, according to its help page on sitemaps. You submit it in Search Console.

The Change of Address tool is only for a domain change. Google says you do not need it for HTTP to HTTPS, www to non-www, or path changes on the same domain, so a replatform that keeps its domain skips it.

Last, your own links. Menus, footer links, blog posts with hardcoded old paths and ad campaigns should point at the new URLs, not lean on a redirect forever.

Why do some Shopify redirects never fire?

Shopify only redirects from URLs that are broken. Its URL redirect help page puts it plainly: “If the URL still loads a valid webpage, then the URL redirect won’t work.” If a product, page or collection still lives at the old path, the redirect from that path does nothing.

The same page says that if you hide a collection, redirect its URL, and later make the collection visible again, “the URL redirect is automatically deleted”. The page says the same behaviour applies to products. So a redirect you tested in week one can be gone in week three.

The page also lists paths you cannot redirect at all, including URLs beginning /apps, /cart, /orders, /services and /shop, the fixed paths /products, /collections and /collections/all, and collection tag-filter URLs such as /collections/shirts/tag-name. A WooCommerce store with a page at /shop needs a different answer, which is the subject of our WooCommerce 301 map guide.

Query strings are flagged as well: URLs containing them “might not work as expected”.

On markets, as of November 2026, the help page contradicts itself. One section says “URL redirects don’t automatically apply to all market subfolders”, and a later one says “Creating a URL redirect applies to all language or market subfolders.” If you sell through subfolders such as /en-ca, put a handful of prefixed old URLs in your test file and find out which sentence holds for your store.

How do you test thousands of redirects at once?

With a script that follows each redirect by hand, so it sees every hop; Google’s guide suggests scripts for large URL sets and the URL Inspection tool for single pages. This one reads the same two-column map you imported (old path, new path), requests each old URL with redirect: 'manual', and reports anything that is not a single permanent hop to a 200 page whose canonical matches.

// Usage: node check-redirects.mjs redirects.csv https://www.example.com
// CSV rows: old path or URL, expected new path or URL. Header row is skipped.
import { readFileSync } from 'node:fs';

const [csvPath, origin] = process.argv.slice(2);
const MAX_HOPS = 10;
const PAUSE_MS = 1000; // one request a second; we saw 429s from Shopify on bursts
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function get(url) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    await sleep(PAUSE_MS);
    const res = await fetch(url, { redirect: 'manual' });
    if (res.status !== 429) return res;
    await res.body?.cancel();
    const wait = Number(res.headers.get('retry-after')) || 60;
    console.error(`429 on ${url}, waiting ${wait}s`);
    await sleep(wait * 1000);
  }
  throw new Error(`still rate limited after 3 attempts: ${url}`);
}

async function trace(startUrl) {
  const hops = [];
  let url = startUrl;
  while (hops.length <= MAX_HOPS) {
    const res = await get(url);
    const location = res.headers.get('location');
    if (res.status >= 300 && res.status < 400 && location) {
      await res.body?.cancel();
      hops.push(res.status);
      url = new URL(location, url).href;
      continue;
    }
    let canonical = null;
    if (res.status === 200) {
      const html = await res.text();
      const tag = html.match(/<link[^>]*rel="canonical"[^>]*>/i)?.[0];
      const href = tag?.match(/href="([^"]+)"/i)?.[1];
      if (href) canonical = new URL(href, url).href;
    } else {
      await res.body?.cancel();
    }
    return { hops, status: res.status, finalUrl: url, canonical };
  }
  return { hops, status: 'too many hops', finalUrl: url, canonical: null };
}

function problemsFor(from, want, t) {
  if (t.status === 'too many hops') return ['redirect loop, or more than 10 hops'];
  const problems = [];
  if (t.status !== 200) problems.push(`ends in ${t.status}`);
  else if (t.hops.length === 0 && from !== want) problems.push('no redirect: a live page answers the old URL');
  if (t.hops.length > 1) problems.push(`chain of ${t.hops.length} hops`);
  if (t.hops.some((code) => code !== 301 && code !== 308)) problems.push(`non-permanent hop (${t.hops.join(' > ')})`);
  if (t.finalUrl !== want) problems.push(`lands on ${t.finalUrl}`);
  if (t.canonical && t.canonical !== t.finalUrl) problems.push(`canonical is ${t.canonical}`);
  return problems;
}

const rows = readFileSync(csvPath, 'utf8')
  .split(/\r?\n/)
  .map((line) => line.split(',').map((cell) => cell.trim()))
  .filter(([from, to]) => to && /^(\/|https?:)/.test(from));

let failed = 0;
let toHome = 0;
for (const [fromCell, toCell] of rows) {
  const from = new URL(fromCell, origin).href;
  const want = new URL(toCell, origin).href;
  if (new URL(want).pathname === '/') toHome++;
  const t = await trace(from);
  const problems = problemsFor(from, want, t);
  if (problems.length) {
    failed++;
    console.log(`FAIL\t${fromCell}\t${problems.join('; ')}`);
  }
}
console.log(`${rows.length} checked, ${failed} failed, ${toHome} sent to the home page`);
process.exitCode = failed ? 1 : 0;

It has no dependencies, and we ran it on Node 24.12.0 only. The CSV split is naive, so a path containing a comma breaks its row. Because it exits non-zero on any failure, it can run as a scheduled CI job through the first month.

The one-second pause is deliberate. While probing Shopify’s public Dawn demo store (theme-dawn-demo.myshopify.com) on 8 November 2026, back-to-back curl requests drew 429 responses with Retry-After: 60 within the first handful of requests. The paced script hit no 429 in its single five-URL run against the same store. If you do get one, the script waits for the Retry-After period and tries again, up to three times.

What did it catch?

We ran it twice, first against a local mock server we wrote to return each failure on purpose, so these results show the script’s logic, not Shopify’s behaviour. Across the 11 rows it flagged a two-hop chain, a 302, a redirect to a deleted product ending in 404, an old path still answered by a live page, a redirect loop, a page whose canonical pointed at a collection-nested URL, and a row mapped to the home page whose redirect had never been created, so it returned 404. It retried twice through a 429 and passed the row.

Then against the Dawn demo store. We cannot add redirects there, so this tests the checker on real Shopify responses, not a real migration. /collections/all/products/art-deco-cyclamen answered with a single 301 to /products/art-deco-cyclamen, and passed. A capitalised /Products/Art-Deco-Cyclamen returned 404, so on that store at least, paths were case-sensitive. And /collections/all/ with a trailing slash returned 200 without redirecting, with a canonical pointing at the slashless URL.

That last one matters if your old URLs ended in a slash. We could not test how Shopify matches a stored redirect against the trailing-slash form of a path, because that needs a store we can add redirects to. Put both forms of a few URLs in your file and let the checker answer it for your store.

Which failures do you fix first?

Anything ending in 404, because those URLs are losing traffic today.

Next, old URLs answered by a live page. On Shopify that means a page still loads at the old path, so the redirect is ignored. Either the mapping was wrong or the live page is the right destination and the row should be deleted.

Chains after that. Google says Googlebot follows up to 10 hops but advises redirecting to the final destination, “ideally no more than 3 and fewer than 5”, and notes that not all browsers support long chains. A chain appears when a row points at a URL that itself redirects, such as a collection you renamed after building the map. Update the first row to point at the final target.

Temporary redirects last among the redirect problems, but do fix them. Shopify’s help page discusses its redirects as 301s, so if the report shows a 302 or 307, find out what is issuing it before you change any rows.

Watch the home-page count in the summary line as well. Google warns that sending many old URLs to one irrelevant page such as the home page “might be treated as a soft 404”. Discontinued products pointed at / are the obvious candidates; point them at their nearest collection instead.

What should you watch in Search Console for the first fortnight?

Submit sitemap.xml on the day of cutover, then check the indexing reports regularly for unexpected crawl errors, as Google’s guide advises. Google says a small to medium site “can take a few weeks for most pages to move”, and that rankings may fluctuate in that time. A dip on its own is expected; a dip that lines up with a cluster of 404s in the checker output is not.

Re-run the checker weekly. Redirects on Shopify can vanish when a product or collection goes live again, so a clean run on day one does not prove a clean run on day twenty. If you edited robots.txt during the build, check it again too; our Shopify robots.txt guide covers what the default rules do.

Our recommendation: generate the redirect map from the old site’s crawl and its sitemap, import it, and run this checker against the live domain within an hour of DNS switching, then weekly for a month. Fix 404s the same day. Skip the script only for a store with a few dozen URLs, where the URL Inspection tool in Search Console is quicker.

We move stores onto Shopify from WooCommerce, Magento, BigCommerce and bespoke platforms, redirect map and post-cutover checks included. That work sits within our Shopify development services.

Need this built properly?

Get in touch