Squarespace to Shopify Migration: Script the CSV

A Squarespace to Shopify migration comes down to three CSV exports and one XML file. Squarespace exports products, contacts and orders as CSV, and pages and blog posts only as WordPress XML. None of those files imports into Shopify unedited, and nothing builds your redirects for you. Expect to write a converter for the products and map the URLs yourself.

Shopify’s own Squarespace migration guide lists the column edits one at a time, which is fine for twenty products. Past a hundred it is a spreadsheet afternoon you will repeat every time the client adds stock before launch. This post is the scripted version, plus the parts that guide leaves to you.

What can you export from Squarespace?

Less than the client thinks.

Products come out of Products & Services > Products > Export all, capped at 10,000. Shopify’s guide describes that export as physical and service products only, and Squarespace’s product export article says the file leaves out featured images, variant images, image alt text, customer reviews and additional product information. Digital download products do not come through, so plan to rebuild them by hand.

Customers come from Contacts > Export all contacts. That file has no contact notes and no saved addresses. The one exception is the address from each customer’s most recent physical order. It also has no country column, and Shopify’s customer template wants an ISO country code, so you have to add one. If the client has years of email subscribers, the consent fields matter more than the addresses: our guide to moving customers without losing marketing consent covers the API route that keeps the original opt-in dates.

Orders export from Orders > Export data > Download CSV. Export fulfilled orders only, and note that the date filter uses the order creation date, not the fulfilment date. For the import side, Shopify’s guide sends you to a third-party app and names EZ Importer.

Pages and blog posts are the awkward ones. Squarespace’s site export writes a WordPress-format .xml file with layout pages, one blog page and its posts, and text and image blocks. Store pages, product blocks, video and audio blocks, drafts and custom CSS do not export, and neither does any second blog. Shopify’s guide says to copy posts across manually. For more than a handful, parse the XML yourself and push each post through the same articleCreate loop we used to migrate a WordPress blog to Shopify. Only the input changes.

Reviews do not export at all. They have to be collected again through a review app.

Which Squarespace CSV columns map to Shopify?

Most of the product mapping is renaming headers. Six columns need more than a rename, and they are where a hand-edited import goes wrong.

The handle is the first. Squarespace fills product-level fields only on a product’s first row, and each row after it carries one variant with Product URL left empty. Shopify groups variants by URL handle, so every one of those rows needs the handle copied down, or it imports as an orphan.

Sale prices invert. Squarespace stores Price, Sale Price and an On Sale flag; Shopify stores Price and Compare-at price. When On Sale is Yes, the old price moves to compare-at and the sale price becomes the price.

Weight is exported in whatever unit the Squarespace store uses, and Shopify’s Weight value (grams) takes grams. Multiply pounds by 453.6 or kilograms by 1,000.

Stock can be a word. Squarespace allows unlimited, while Shopify’s Inventory quantity is a number and only applies to single-location stores. We treat unlimited as untracked, with a blank Inventory tracker, rather than inventing a quantity that will one day run out.

Images arrive packed into one Hosted Image URLs cell, separated by spaces or line breaks. Shopify’s product CSV format wants one Product image URL per row, and each extra row carries only the handle, the URL and an Image position.

Categories have no direct equivalent. They are paths like /clothing/blouses, and Shopify’s guide suggests collapsing each one to a single Type, then rebuilding the groupings as collections. Make those collections automated on Type or tags so they stay correct as products are added.

The rest really is renaming. Visible becomes Published on online store, with Yes/No turned into true/false. A product without options still needs Option1 name set to Title and Option1 value set to Default Title, because that is how Shopify represents a single-variant product.

A script that converts the product export

This reads SquarespaceProductDownload.csv, writes a Shopify product CSV, and writes a redirect CSV from the Product Page column, which holds the slug of the store page each product sits on. It uses only Node built-ins, because a CSV library is not worth a dependency here. The parser handles quoted fields, because product descriptions routinely contain commas, quotes and line breaks.

// Usage: node sqsp-to-shopify.mjs SquarespaceProductDownload.csv kg|lb
// Writes shopify-products.csv and redirects.csv into the current directory.
import { readFileSync, writeFileSync } from 'node:fs';

const [input, unit] = process.argv.slice(2);
const toGrams = { kg: 1000, lb: 453.6 }[unit];
if (!input || !toGrams) {
  console.error('Usage: node sqsp-to-shopify.mjs <export.csv> <kg|lb>');
  process.exit(1);
}

// Minimal RFC 4180 parser: descriptions carry commas, quotes and newlines.
function parseCsv(text) {
  const rows = [];
  let row = [];
  let field = '';
  let quoted = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (quoted) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (c === '"') quoted = false;
      else field += c;
    } else if (c === '"') quoted = true;
    else if (c === ',') { row.push(field); field = ''; }
    else if (c === '\n' || c === '\r') {
      if (c === '\r' && text[i + 1] === '\n') i++;
      row.push(field); rows.push(row); row = []; field = '';
    } else field += c;
  }
  if (field || row.length) { row.push(field); rows.push(row); }
  const [header, ...body] = rows;
  return body
    .filter((r) => r.some((v) => v.trim()))
    .map((r) => Object.fromEntries(header.map((h, j) => [h.trim(), r[j] ?? ''])));
}

const cell = (v) => (/[",\n\r]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v);
const toCsv = (cols, rows) =>
  [cols, ...rows.map((r) => cols.map((c) => String(r[c] ?? '')))]
    .map((r) => r.map(cell).join(','))
    .join('\n') + '\n';

const COLS = [
  'URL handle', 'Title', 'Description', 'Vendor', 'Type', 'Tags',
  'Published on online store', 'Status', 'SKU',
  'Option1 name', 'Option1 value', 'Option2 name', 'Option2 value',
  'Option3 name', 'Option3 value', 'Price', 'Compare-at price',
  'Inventory tracker', 'Inventory quantity', 'Weight value (grams)',
  'Product image URL', 'Image position',
];

// "/clothing/blouses, /sale" -> "Blouses"
function typeFrom(categories) {
  const leaf = categories.split(',')[0].trim().split('/').filter(Boolean).pop() ?? '';
  const words = leaf.replaceAll('-', ' ');
  return words.charAt(0).toUpperCase() + words.slice(1);
}

const products = [];
let current = null;
for (const r of parseCsv(readFileSync(input, 'utf8'))) {
  // Only the first row of a product carries Title and Product URL.
  if (r['Title'] || r['Product URL']) {
    current = { first: r, variants: [] };
    products.push(current);
  }
  if (!current) throw new Error('CSV starts with an option row');
  current.variants.push(r);
}

const out = [];
const redirects = [];
for (const { first, variants } of products) {
  const handle = first['Product URL'].trim().toLowerCase();
  const images = (first['Hosted Image URLs'] ?? '').split(/\s+/).filter(Boolean);

  variants.forEach((v, i) => {
    const onSale = (v['On Sale'] || first['On Sale']) === 'Yes';
    const stock = v['Stock'].trim().toLowerCase();
    const row = {
      'URL handle': handle,
      SKU: v['SKU'],
      Price: onSale ? v['Sale Price'] : v['Price'],
      'Compare-at price': onSale ? v['Price'] : '',
      // "unlimited" stock means Shopify should not track inventory at all
      'Inventory tracker': stock === 'unlimited' ? '' : 'shopify',
      'Inventory quantity': stock === 'unlimited' ? '' : stock.replace(/\D/g, ''),
      'Weight value (grams)': v['Weight'] ? Math.round(Number(v['Weight']) * toGrams) : '',
    };
    for (const n of [1, 2, 3]) {
      const value = v[`Option Value ${n}`];
      if (value) {
        row[`Option${n} name`] = v[`Option Name ${n}`] || first[`Option Name ${n}`];
        row[`Option${n} value`] = value;
      }
    }
    if (!row['Option1 value']) {
      row['Option1 name'] = 'Title';
      row['Option1 value'] = 'Default Title';
    }
    if (i === 0) {
      Object.assign(row, {
        Title: first['Title'],
        Description: first['Description'],
        Vendor: first['Default Vendor Name'] ?? '',
        Type: typeFrom(first['Categories'] ?? ''),
        Tags: first['Tags'],
        'Published on online store': first['Visible'] === 'Yes' ? 'true' : 'false',
        Status: 'active',
        'Product image URL': images[0] ?? '',
        'Image position': images.length ? 1 : '',
      });
    }
    out.push(row);
  });

  // Extra images: one row each, handle plus URL and position only.
  images.slice(1).forEach((url, i) => {
    out.push({ 'URL handle': handle, 'Product image URL': url, 'Image position': i + 2 });
  });

  const page = (first['Product Page'] ?? '').trim().replace(/^\/|\/$/g, '');
  if (page) redirects.push({ from: `/${page}/p/${handle}`, to: `/products/${handle}` });
  else console.error(`No Product Page for ${handle}: add its redirect by hand`);
}

writeFileSync('shopify-products.csv', toCsv(COLS, out));
writeFileSync('redirects.csv', toCsv(['Redirect from', 'Redirect to'],
  redirects.map((r) => ({ 'Redirect from': r.from, 'Redirect to': r.to }))));
console.error(`${products.length} products, ${out.length} rows, ${redirects.length} redirects`);

Run it with the store’s weight unit:

node sqsp-to-shopify.mjs SquarespaceProductDownload.csv kg

We ran it under Node 24 against a sample file built from the column names Squarespace documents. Check those names against the client’s real export before you trust it. Squarespace’s own help pages spell them inconsistently (Option Name 1 in one place, Option names 1-3 in another), and a header that does not match makes the script read an empty string without complaint. Then import the output in Products > Import with Publish new products to all sales channels unticked, and spot-check prices, weights and stock on a few variants before anyone sees the storefront.

How do Squarespace URLs map to Shopify redirects?

Squarespace 7.1 product URLs are the store page slug, then p/, then the product slug, so /shop/p/linen-shirt. Squarespace’s URL slug docs say the p/ is always there and cannot be removed. That makes products the one mapping a script can do mechanically, and it is why the converter above writes redirects.csv as it goes.

The rest you build by hand:

  • Pages move from /about to /pages/about.
  • Blog posts move from /blog/post-slug to /blogs/news/post-slug (or whichever blog handle you create).
  • Category links on many Squarespace sites are /shop?category=Shirts. Shopify’s URL redirect docs warn that query strings “might not work as expected” with redirects, so redirect each store page’s bare path to its collection and test the filtered URLs after launch rather than assuming they follow.

Two Shopify rules catch people out here. A redirect only fires when the old path is broken on Shopify, so a Squarespace /search or /cart URL that Shopify already serves will never redirect. And standard plans allow 100,000 redirects, which no Squarespace store is going to approach, so there is no reason to redirect only the top pages. Redirect everything you can find in the client’s analytics and Search Console.

The domain is the last step, and the order matters. Load the redirects before the domain moves. Shopify’s guide says to disconnect the domain from Squarespace first or risk SSL certificate errors, and warns that Squarespace can lock a domain for 60 days after registration or a change of contact details. Check that before you book the cutover date. Domains registered with Google Domains are now managed by Squarespace too, so a client who thinks their domain is “with Google” is in the same position.

When to script a Squarespace to Shopify migration

Under about thirty products, skip the script. Edit Shopify’s template by hand or create the products in the admin, because that is quicker than checking a converter’s output. From there up to the 10,000-product ceiling, the script pays for itself the first time the client changes stock levels after you have already done one import. If order history matters to the client for reorders or accounting, pay for the migration app rather than writing an order importer.

The honest test comes before any of this. If the Squarespace site is mostly pages and a blog with a dozen products on the side, the content the export leaves behind is the bulk of the site, and the move will cost more in rebuilding than Shopify’s checkout gives back. Migrate when the shop is the business.

We move stores onto Shopify, from the product CSV through to the redirect map and the domain cutover, and build the theme they land on. If you are weighing a Squarespace move, our Shopify development work is where we would start the conversation.

Need this built properly?

Get in touch