Shopify will not take your WooCommerce URLs as they are. Products move from /product/<slug>/ to /products/<handle>, categories from /product-category/<slug>/ to /collections/<handle>, and a handful of WooCommerce paths cannot be redirected on Shopify at all. Build the WooCommerce to Shopify redirect map from your real URL list, then import it through the Admin API.
Shopify’s own WooCommerce migration guide is honest about the gap. The Store Migration app moves products. Customers go across as a hand-edited CSV, orders need a third-party app, and redirects are left to you as an optional step at the end. That “optional” step is the one that decides whether you keep your organic traffic.
Building the WooCommerce to Shopify redirect map
Start with the URLs, not the products. Pull three lists and take the union: the XML sitemap from Yoast or Rank Math, the indexed-pages export from Search Console, and a month of access logs filtered to 200 responses. The sitemap tells you what you published. Search Console tells you what Google actually holds. The logs catch the old URLs nobody remembers, which are usually the ones with links pointing at them.
Do this before DNS moves. Once the WooCommerce site is gone you cannot crawl it again.
Then work out which permalink structure the store used, because it changes the whole map. WooCommerce’s permalink settings offer four product bases: Default, which gives /product/<slug>/ once WordPress is on pretty permalinks; Shop base, giving /shop/<slug>/; Shop base with category, giving /shop/<category>/<slug>/; and a custom base. Category archives default to /product-category/<slug>/ and tags to /product-tag/<slug>/.
Shopify’s side is fixed. Products live at /products/<handle>, collections at /collections/<handle>, static pages at /pages/<handle>, and articles at /blogs/<blog-handle>/<article-handle>. There is no setting to change any of that, so the mapping is mechanical once you have a slug-to-handle lookup out of your product import.
// build-redirects.mjs
// Usage: node build-redirects.mjs urls.txt handles.json > redirects.csv
import { readFileSync } from 'node:fs';
const [urlFile, handleFile] = process.argv.slice(2);
const handles = JSON.parse(readFileSync(handleFile, 'utf8'));
const RESERVED = ['/apps', '/application', '/cart', '/carts', '/orders', '/services', '/shop'];
function target(segments) {
const [base, ...rest] = segments;
const last = rest.at(-1);
if (base === 'product') return `/products/${handles.products[last] ?? last}`;
if (base === 'product-category') return `/collections/${handles.collections[last] ?? last}`;
if (base === 'product-tag') return `/collections/${handles.collections[last] ?? last}`;
return null;
}
const rows = ['Redirect from,Redirect to'];
const skipped = [];
for (const line of readFileSync(urlFile, 'utf8').split('\n')) {
const raw = line.trim();
if (!raw) continue;
const path = new URL(raw).pathname.replace(/\/+$/, '');
if (RESERVED.some((p) => path === p || path.startsWith(`${p}/`))) {
skipped.push(`${path}\treserved prefix`);
continue;
}
const to = target(path.split('/').filter(Boolean));
if (to) rows.push(`${path},${to}`);
else skipped.push(`${path}\tno rule`);
}
console.log(rows.join('\n'));
console.error(`skipped ${skipped.length}\n${skipped.join('\n')}`);
The Redirect from,Redirect to header is what Shopify’s importer expects, and the sample template has the same two columns. Everything the script pushes to stderr is your manual pile. Sort that by clicks and work down until the remainder is noise.
Which WooCommerce URLs will Shopify refuse
This is where most migration plans quietly lose pages. Shopify’s URL redirect rules block redirects from paths starting /apps, /application, /cart, /carts, /orders, /services or /shop, and from the fixed paths /products, /collections and /collections/all.
Read that list again with WooCommerce in mind. /shop is reserved. If the store used the Shop base or Shop base with category permalink option, every single product URL you need to redirect starts with /shop/, and Shopify’s redirect system will not accept any of them. The same applies to /cart, which WooCommerce creates as a page by default.
There is no way around that inside Shopify. You handle it at the edge instead, with a Cloudflare bulk redirect list or equivalent, before the request reaches the origin. Budget for it as separate work rather than finding it on cutover day.
Two more limits. Shopify only redirects broken URLs, so a path that already returns a page keeps serving that page and ignores your rule. And the ceiling is 100,000 redirects on standard plans, 20,000,000 on Plus.
Query-string URLs are also out. WooCommerce’s plain permalink mode produces /?product=111, and those need edge handling too.
How to import thousands of redirects with the Admin API
The admin UI takes a CSV, but it is a click path and it gives you no record of what ran. For anything past a few hundred rows, use the Admin GraphQL API. These queries are written against version 2026-07, and every mutation below needs the write_online_store_navigation access scope.
Single redirects use urlRedirectCreate:
mutation CreateRedirect($urlRedirect: UrlRedirectInput!) {
urlRedirectCreate(urlRedirect: $urlRedirect) {
urlRedirect { id path target }
userErrors { field message }
}
}
{ "urlRedirect": { "path": "/product/blue-hoodie", "target": "/products/blue-hoodie" } }
Bulk is a three-step dance. Stage an upload, post the file, then create and submit the import.
mutation StageRedirectCsv($input: [StagedUploadInput!]!) {
stagedUploadsCreate(input: $input) {
stagedTargets {
url
resourceUrl
parameters { name value }
}
userErrors { field message }
}
}
{
"input": [{
"resource": "URL_REDIRECT_IMPORT",
"filename": "redirects.csv",
"mimeType": "text/csv",
"httpMethod": "POST"
}]
}
Post the CSV to the returned url as multipart form data, with every returned parameters name/value pair sent as a field before the file. Then hand the resourceUrl to urlRedirectImportCreate, which takes a single url: URL! argument and returns a UrlRedirectImport.
Do not submit it straight away. Query the import first:
query CheckImport($id: ID!) {
urlRedirectImport(id: $id) {
count
previewRedirects { path target }
}
}
count is the number of rows Shopify parsed, and previewRedirects gives you up to three parsed pairs. If count is off by a few thousand, your CSV has a quoting problem and you want to know now rather than after 40,000 rows land. When it looks right, urlRedirectImportSubmit(id: $id) returns a job, and polling the import gives you finished, createdCount, updatedCount and failedCount.
Why collection-scoped product URLs are a trap
Shopify serves the same product at /products/<handle> and at /collections/<handle>/products/<handle>, with the canonical tag pointing at the short form. Both resolve, so neither can be redirected. If the WooCommerce store used Shop base with category permalinks, the temptation is to rebuild that nesting to keep the URLs feeling familiar. Resist it. You gain nothing and you hand yourself a duplicate URL set that internal links will leak into.
One recent wrinkle on the WooCommerce side. In January 2026 WooCommerce announced a change for 10.5 to how the category segment is picked when a product sits in several categories: the deepest category in the hierarchy now wins, rather than the lowest parent term ID. Existing URLs keep working through WooCommerce’s own canonical 301s, but the crawled URL set on a %product_cat% store may have shifted underneath you. Crawl, do not assume.
If you are weighing up how far to rebuild rather than port, our comparison of Hydrogen and Next.js for a Shopify storefront covers the storefront decision, and the walkthrough of cart mutations on the Storefront API covers what a custom front end has to implement.
What to check in the first fortnight
Sample the map rather than trusting it. Twenty or thirty rows across every URL shape, checked for a single 301 hop to a 200:
tail -n +2 redirects.csv | shuf -n 30 | while IFS=, read -r from to; do
result=$(curl -sI -o /dev/null -w '%{http_code} %{redirect_url}' "https://example.com${from}")
printf '%s\t%s\t%s\n' "$from" "$to" "$result"
done
A 302 means something else is answering before Shopify. Two hops usually means http to https, then the redirect, which is survivable but worth fixing at the edge.
After that, watch Search Console. Export the 404s weekly, feed them back through the script, and re-import. Expect impressions to dip for two to four weeks while Google reprocesses; a dip that is still deepening at week six is a redirect problem, not patience.
What I would actually do: crawl and freeze the URL list before touching DNS, map products and categories with the script, check the reserved-prefix pile early enough to build edge rules for it, and import through the API with a preview step. What I would not do is hand-key redirects in the admin UI for a catalogue of any size, or leave redirects until after cutover. Both are how stores lose a quarter of their organic traffic and spend three months getting it back.
Whoooop runs WooCommerce and Magento migrations onto Shopify, including the redirect mapping and the edge rules for paths Shopify will not accept. We also build the storefront afterwards, themed or headless. There is more on that on our Shopify development page.