A Wix to Shopify migration fails first on product options. Wix allows six options per product and Shopify allows three, and Shopify’s own Store Migration app imports a Wix product with four or more options without its options or variants. Count your options before anything else, convert the CSV yourself, then build redirects from the live Wix sitemap, because the export does not contain your URLs.
Everything below is checked against Shopify’s Wix migration guide, Shopify’s product CSV reference and Wix’s import field reference, as of November 2026.
What does the Wix export actually give you?
One CSV of physical products, at most 5,000 rows per file. Wix’s export article says digital products cannot be exported at all, so list those by hand now rather than discovering them on launch day.
The file is row-per-thing. Each product has one row with fieldType set to Product, followed by its variants as rows with fieldType set to Variant, all sharing a handleId. That handleId is what Wix calls “a unique label used to identify the product”. It is not the slug in the product’s URL, which matters later.
The rest maps loosely:
pricesits on the product row. Variants carry asurcharge, positive or negative, added to that price.discountMode(AMOUNTorPERCENT) anddiscountValuedescribe a sale on the product.inventoryisInStock,OutOfStockor a number.collectionis a semicolon-separated list of collection names, andproductImageUrla semicolon-separated list of up to 15 images.productOptionName,productOptionType(COLORorDROP_DOWN) andproductOptionDescriptionrepeat once per option, up to six.customTextField(up to two per product) andadditionalInfoTitle(up to six sections) have no matching column in Shopify.
Customers export separately, and Shopify’s guide says WixCustomerDownload.csv needs editing before a plain CSV import will accept it. Orders have no built-in import; the guide points to Cart2Cart, LitExtension and EZ Importer. Reviews cannot be exported from Wix at all, so plan on a reviews app and a manual import.
Why do Wix products lose their variants?
Shopify caps a product at three options. Since the 2048-variant change, the ceiling on combinations is generous, but the option count has not moved. A Wix product with Size, Colour, Fit and Length is four options, and the Store Migration app brings it across as a single variant with no choices. The product page still renders, with a price and an add-to-cart button. There is just no way to pick a size.
There are three honest fixes, and the right one depends on what the fourth option is.
If two options are really one decision (“Size” and “Fit”), merge them into a single option with values like 32 Regular. This is the usual answer for clothing.
If an option does not change stock or price, such as a gift message or an engraving style, it was never a variant. Move it to a line item property on the product form, which is the same place Wix’s custom text fields should land. Our guide to line item properties covers getting those values through to the order.
If the option is a genuinely different product (a different material with its own photography), split it into separate Shopify products and link them, which is what combined listings exist for.
Decide this per product with the merchant. The script below lists the affected products, but only someone who knows the range can say which option is not really a variant.
How do you convert the Wix CSV to Shopify’s format?
Shopify’s current product CSV uses plain-English headers: URL handle, Title, Description, Option1 name, Option1 value, Price, Compare-at price, Product image URL, Image position and so on. One row per variant, sharing the URL handle, and extra images go on their own rows with just the handle, the image URL and a position. The file must stay under 15 MB.
This Node script does the conversion and refuses to guess on anything over three options. It uses csv-parse and csv-stringify (npm i csv-parse csv-stringify) and runs as node wix-to-shopify.mjs wix-export.csv.
// wix-to-shopify.mjs
import { readFileSync, writeFileSync } from 'node:fs';
import { parse } from 'csv-parse/sync';
import { stringify } from 'csv-stringify/sync';
// If image values are bare file names, set this from one image address on the live site.
const IMAGE_BASE = process.env.IMAGE_BASE ?? '';
const HEADERS = [
'Title', 'URL handle', 'Description', 'Collection', 'Tags', 'Status', 'SKU',
'Option1 name', 'Option1 value', 'Option2 name', 'Option2 value', 'Option3 name', 'Option3 value',
'Price', 'Compare-at price', 'Inventory tracker', 'Inventory quantity',
'Product image URL', 'Image position',
];
const rows = parse(readFileSync(process.argv[2]), { columns: true, bom: true, skip_empty_lines: true });
const optionSlots = Object.keys(rows[0])
.filter((k) => /^productOptionName\d+$/.test(k))
.map((k) => k.replace('productOptionName', ''));
const slugify = (s) => s.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const choice = (v = '') => v.replace(/^#[0-9a-f]{3,8}:/i, '').trim(); // "#000000:black" -> "black"
const money = (n) => n.toFixed(2);
const stock = (inv = '') =>
inv === 'OutOfStock' ? ['shopify', '0'] : /^\d+$/.test(inv) ? ['shopify', inv] : ['', ''];
const products = new Map();
for (const row of rows) {
const entry = products.get(row.handleId) ?? { product: null, variants: [] };
if (row.fieldType === 'Product') entry.product = row;
else entry.variants.push(row);
products.set(row.handleId, entry);
}
const out = [];
const needsDecision = [];
const seen = new Set();
for (const { product: p, variants } of products.values()) {
if (!p) continue;
const slots = optionSlots.filter((n) => p[`productOptionName${n}`]);
if (slots.length > 3 || (slots.length > 0 && variants.length === 0)) {
needsDecision.push(`${p.name} (${slots.length} options, ${variants.length} variant rows)`);
continue;
}
const base = slugify(p.name);
let handle = base;
for (let n = 2; seen.has(handle); n++) handle = `${base}-${n}`;
seen.add(handle);
const collections = (p.collection ?? '').split(';').map((c) => c.trim()).filter(Boolean);
const images = (p.productImageUrl ?? '').split(';').filter(Boolean)
.map((u) => (u.startsWith('http') ? u : IMAGE_BASE + u));
const prices = (surcharge) => {
const full = Number(p.price) + Number(surcharge || 0);
const off = Number(p.discountValue || 0);
if (p.discountMode === 'PERCENT') return [full * (1 - off / 100), full];
if (p.discountMode === 'AMOUNT') return [full - off, full];
return [full, null];
};
const lines = variants.length ? variants : [p];
lines.forEach((v, i) => {
const [price, compareAt] = prices(v === p ? 0 : v.surcharge);
const [tracker, qty] = stock(v.inventory || p.inventory);
const row = {
'URL handle': handle,
SKU: v.sku ?? '',
Price: money(price),
'Compare-at price': compareAt === null ? '' : money(compareAt),
'Inventory tracker': tracker,
'Inventory quantity': qty,
};
slots.forEach((n, j) => {
if (i === 0) row[`Option${j + 1} name`] = p[`productOptionName${n}`];
row[`Option${j + 1} value`] = choice(v[`productOptionDescription${n}`]);
});
if (i === 0) {
Object.assign(row, {
Title: p.name,
Description: p.description ?? '',
Collection: collections[0] ?? '',
Tags: [...collections, p.ribbon].filter(Boolean).join(', '),
Status: 'draft',
'Product image URL': images[0] ?? '',
'Image position': images[0] ? '1' : '',
});
}
out.push(row);
});
images.slice(1).forEach((src, k) =>
out.push({ 'URL handle': handle, 'Product image URL': src, 'Image position': String(k + 2) }));
}
writeFileSync('shopify-products.csv', stringify(out, { header: true, columns: HEADERS }));
if (needsDecision.length) console.warn(`Needs a decision before import:\n${needsDecision.join('\n')}`);
Four assumptions are built in, and you should check each against one real product before trusting the output. The script reads each variant’s choice from its productOptionDescription columns, because Wix’s article is vague about which column carries it on variant rows; open your export and confirm. It applies the sale to the surcharged price, so compare one discounted variant with the live Wix page. It only tracks stock when Wix had a number or OutOfStock, and Shopify’s Inventory quantity column only works for a single location. And every product imports as draft, which matches what the Store Migration app does and gives you a review step.
Shopify’s Collection column is import-only and takes one collection name, so the script uses the first and copies all of them into tags. Build automated collections on those tags afterwards, so a re-import keeps products in the right collections without anyone sorting them by hand. Wix’s additional info sections belong in product metafields, which the CSV accepts as columns named like Care (product.metafields.custom.care).
How do Wix URLs map to Shopify redirects?
By default Wix serves products at /product-page/{slug} and blog posts at /post/{slug}, per its URL structure settings. The merchant may have changed either prefix, so read the live site rather than the defaults. Shopify serves products at /products/{handle} and articles at /blogs/{blog}/{handle}.
Since the export has no slugs, the live sitemap is your source of URLs. This script walks it, writes a file in the exact shape of Shopify’s sample template (Redirect from,Redirect to), and warns about any product URL whose slug did not become a handle in the converted CSV:
// wix-redirects.mjs (node wix-redirects.mjs https://www.example.com)
import { readFileSync, writeFileSync } from 'node:fs';
import { parse } from 'csv-parse/sync';
const site = process.argv[2];
const handles = new Set(
parse(readFileSync('shopify-products.csv'), { columns: true }).map((r) => r['URL handle']),
);
async function locs(url) {
const xml = await (await fetch(url)).text();
const found = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
if (!xml.includes('<sitemapindex')) return found;
return (await Promise.all(found.map(locs))).flat();
}
const lines = ['Redirect from,Redirect to'];
for (const loc of await locs(`${site}/sitemap.xml`)) {
const { pathname } = new URL(loc);
const product = pathname.match(/^\/product-page\/([^/]+)$/);
const post = pathname.match(/^\/post\/([^/]+)$/);
if (product) {
if (!handles.has(product[1])) console.warn(`No matching handle: ${pathname}`);
lines.push(`${pathname},/products/${product[1]}`);
} else if (post) {
lines.push(`${pathname},/blogs/news/${post[1]}`);
}
}
writeFileSync('redirects.csv', lines.join('\n') + '\n');
Every warning is a product whose Wix slug differs from its slugified name. Either rename the handle in shopify-products.csv to the Wix slug before importing, which keeps the URLs identical apart from the prefix, or fix the redirect target by hand. Renaming the handle is less work later.
Import the file under Content, Menus, URL redirects. Standard plans allow 100,000 redirects. Shopify only applies a redirect when the old path would otherwise 404, and it warns that URLs with query strings may not behave, so test the important ones after cutover. Our migration SEO checklist has the full list for the first fortnight.
Blog content itself is outside Shopify’s Wix guide. The redirect script assumes the default news blog; once you have the post bodies, Shopify’s Admin API articleCreate mutation is the scripted way to recreate them.
Store Migration app or a script?
Use the Store Migration app when every product has three options or fewer and the catalogue is small enough to check by eye. It reads the Wix CSV unedited, so there is nothing to convert.
Run the script above when any product has four or more options, when products are on sale, or when you will re-import as the merchant keeps trading on Wix before launch. The app’s silent drop of options is the failure we would least like to find after go-live, and a script that lists those products up front turns it into a conversation with the merchant instead.
Either way, run the redirect script, because neither the app nor Shopify builds those for you.
We take on migrations onto Shopify from Wix and other platforms, and on a Wix shop the option count is the first thing we check. If you would rather hand the catalogue mapping and redirect testing to someone who has done it before, that is part of our Shopify development work.