---
title: 'Moving from WooCommerce'
description: 'What the engine does today when a WooCommerce shop moves to Cartwright, what is not built, and what you do yourself on the day the domain switches.'
canonical: 'https://cartwright.app/docs/migrate/woocommerce'
---

# Moving from WooCommerce (/docs/migrate/woocommerce)



This is a status page, not an importer manual: &#x2A;*there is no WooCommerce importer in the engine.** What exists are building blocks — among them a product CSV import, a product scrape from one product page, redirects imported from a CSV with a dry run first, URL options for WordPress-shaped addresses, and nested categories — and you assemble the move from them. Cartwright never runs on PHP or WordPress hosting: it replaces the shop.

Where a statement about the engine rests on one file, that file is named. The page describes engine v0.62.0; a line marked "from the release after v0.62.0" is on the engine's `main` branch and not yet in a tagged release.

## What the engine does today [#what-the-engine-does-today]

### Old addresses keep working through redirects [#old-addresses-keep-working-through-redirects]

* **Admin-managed redirects** live in `/admin/redirects`: one old path at a time, or — since v0.59.0 — many at once from CSV text with the columns `from,to,status`, pasted into the page (`lib/redirects/import.ts`). The page's preview (dry run) writes nothing and shows how many rows are valid, how many have errors and how many replace a saved redirect, with every error by its line (`app/admin/redirects/RedirectsManager.tsx`); it does not list the valid rows. The import then saves all valid rows in one transaction. The same import is the `redirects.import` tool (`lib/tools/redirects.ts`): `dryRun: true` returns the plan — every row that would be saved, with its line, and every error — and `confirm: true` applies it, scope `settings:write`.
* **The rules** (`lib/redirects/import.ts`): at most 2,000 rows and 256 KB per file; status `301` (the default) or `302`. A row whose source already has a saved redirect replaces it. A source repeated in the file, a chain — a destination that is itself a redirect source, in the file or already saved — and a loop are refused, so point every old address straight at its final page. Write the destination without `/da` or `/en`: the redirect keeps the reader's language. A source is compared with the request's path character for character, without decoding it (`lib/redirects/match.ts`), and a request carries every `æ`, `ø` and `å` in its path percent-encoded. So copy each such old path exactly as the old site's own links write it — `/r%c3%b8d-stol` for an `ø` — and, because the case of those letters counts and a browser sends a typed `ø` as `%C3%B8`, add a second row for `/r%C3%B8d-stol`; a row for `/rød-stol` itself never matches.
* **Seven Danish segments answer first.** Before it looks at your redirects, the proxy sends `/produkt/…`, `/kategori/…`, `/kurv`, `/konto`, `/kontakt`, `/ordre` and `/anmeld`, with or without a configured language in front, to their English twin with a 301 (`LEGACY_SLUG_MAP` in `proxy.ts`). So `/produkt/<slug>/` reaches the product without a redirect row: at `/product/<slug>/` with `trailingSlash` on, and at `/product/<slug>` with it off, since Next's own `308` drops the slash first. With the default `localePrefix: "always"`, next-intl then adds the language (step 9 below). A row whose source starts with one of those segments never fires: if the slug changed, the row's source has to be the English path, which makes it a second hop.
* **No Redis needed.** Since v0.62.0 the proxy reads the redirects from the database when Upstash Redis is not set up (`lib/redirects/load.ts`), cached for 60 seconds per server instance, so a new redirect answers within about a minute.
* **The redirect table matches paths only.** The proxy looks a row up by the request's path, not its query string (`lib/redirects/match.ts`), and appends the request's query to the destination (`proxy.ts`). So `/admin/redirects` cannot redirect a query-string address such as `/?p=123`, the shape of WordPress's "Plain" permalink setting. A row with that source is accepted and the dry run counts it valid, but the request's path is `/`, so the row never fires; `/` itself is refused as a source. Such an address can still be redirected ahead of the proxy, with a `next.config.ts` rule that matches the query or a host rule (below).
* **Some paths never reach your redirects.** A row is accepted for any path and the dry run counts it valid, but the proxy looks a redirect up only for a request it runs on, and only after its own branches (`proxy.ts`), so a row for one of these is saved and never answers: a path with a dot anywhere in it — an upload under `/wp-content/uploads/`, a `.php` file, an old XML sitemap such as `/sitemap_index.xml`, a permalink ending in `.html` — which the proxy's `matcher` leaves out, so Next answers it on its own, with a `404` unless the new shop has a file or route at that address; a path that begins with `/hero` — `/heroes/`, `/hero-sale/` — which the same `matcher` leaves out by name, as it does `favicon.ico`, `opengraph-image`, `twitter-image`, `apple-icon`, `_next/static` and `_next/image`; a path that begins with `/api` — `/apiary/` as well, since that check has no slash after it — `/admin` and everything below `/admin/`, and `/oauth` and everything below it (`lib/locale-exempt.ts`); and, under `localePrefix: "never"`, three more: `/om-os`, which `next.config.ts` sends to `/about` before the proxy runs; `/info/about`, `/info/om-os`, `/info/contact` and `/info/privacy`, which the proxy sends to `/about`, `/contact` and `/privacy` first (`lib/canonical-public-routes.ts`); and any other path that begins with `/admin`, such as `/administration/` or `/admin-old/`, which the lookup skips because its `/admin` check has no slash after it either. With the default `"always"` all three reach your row. The two rules need a language in the path, so an old `/om-os/` reaches it — and without a row it still ends on the About page. And `/administration/` is skipped only on its first request: next-intl's `307` adds the language, and your row answers the request after it (`/en/administration`), one hop later.
* **What to do with those instead.** WordPress's own files — `.php`, old sitemaps such as `/sitemap_index.xml` — need nothing: they answer `404`, and you submit the new `/sitemap.xml` in their place (step 9). For an address that still matters, such as an image other sites link to, redirect it ahead of the proxy: in `redirects()` in the shop's own `next.config.ts`, which Next runs before the proxy whatever its `matcher` leaves out — the engine sends `/favicon.ico` to `/icon` that way — or with your host's or CDN's redirect rules.

**Query-string addresses in `next.config.ts`.** A rule in `redirects()` can require a query value with `has` and `type: "query"` (Next's `redirects` reference, under "Header, Cookie, and Query Matching"), and Next runs it before the proxy:

```ts title="next.config.ts"
// two entries for the array that redirects() returns
{
  // ?p= carries a WordPress post ID, so each ID needs its own rule
  source: "/",
  has: [{ type: "query", key: "p", value: "123" }],
  destination: "/product/blue-chair",
  permanent: true,
},
{
  // WooCommerce's plain product address, /?product=<slug>: one rule covers every product that kept its slug
  source: "/",
  has: [{ type: "query", key: "product", value: "(?<slug>[a-z0-9-]+)" }],
  destination: "/product/:slug",
  permanent: true,
},
```

Each answers with a `308` (not the table's `301`), and Next keeps the request's query on the destination (`/product/blue-chair?p=123`), as the table does. With `trailingSlash` on, end the destination in `/`, as the engine's own `/om-os` rule does: under `localePrefix: "never"` a destination without it takes a second `308` to the slash form; under the default `"always"` next-intl's `307` adds the slash either way. The rules are code, so a new one answers after the next deploy. A host or CDN rule that can match a query string does the same job.

### WordPress-shaped addresses: the `urls` block [#wordpress-shaped-addresses-the-urls-block]

Three options in `brand.config.ts`, built in `lib/urls.ts` and documented in the engine's `docs/urls.md` — the full reference is [URL options](/docs/configuration/urls). They shape the new shop's own addresses; nothing here reads the old site, and the importer that would is not built:

* `trailingSlash: true` makes every page path end in `/`, the way WordPress addresses do, and since v0.60.0 it steers routing too: a storefront page asked for without the slash, by `GET` or `HEAD`, answers with a permanent 308 to the slash form. `/api`, `/admin`, `/oauth`, `/icon`, `/og`, `/.well-known` and files answer with or without it.
* `localePrefix: "never"` drops the language segment — `/en/` in a shop from `npx create-cartwright`, which starts English-only — from every path, on a shop with exactly one language (since v0.61.0).
* With both on a single-language shop, `/product/<slug>/` is served as it is — the shape of WooCommerce's default product permalink — so a product imported with its old slug answers at its old address without a redirect row. That needs an old slug the shop can keep: a Cartwright slug holds only `a–z`, `0–9` and `-`, so a slug with `æ`, `ø` or `å` gets a new one and its old address a row. With the default `localePrefix: "always"` the same address first takes one temporary redirect to its language version — `/en/product/<slug>/` in a new scaffold (step 9). WooCommerce's default category base, `/product-category/<slug>/`, is not a Cartwright route (categories live at `/category/<slug>/`), so categories need redirect rows. WooCommerce lets each shop choose its bases, so read yours off the old site.

### Permalinks: stored and linked, not yet served [#permalinks-stored-and-linked-not-yet-served]

Products and categories have a `permalink` column (`prisma/schema.prisma`, since v0.59.0). With `urls.permalinks: true`, the engine's own links to such an entity use the stored address: from v0.62.0 the product and category pages' canonical and breadcrumbs, the sitemap, the product card, the footer and the cart, among others; the header, the mobile menu and the category page's "Explore more" list from the release after v0.62.0 (`components/Header.tsx`, `components/MobileMenu.tsx`, `app/[locale]/category/[slug]/page.tsx`). Every one of them builds the address in `lib/urls.ts`.

Two parts are not built: no import, admin field or tool writes the column, and the shop does not answer at a stored permalink — the option changes the links, not the addresses the shop serves (`docs/urls.md`). Keep `permalinks: false` on a live shop and keep old addresses with redirects.

### Products: the product CSV import [#products-the-product-csv-import]

`/admin/produkter/import` (`lib/products-csv.ts`, since v0.10.0); the matching export is `/admin/produkter/export`.

* **Ten fixed columns:** `slug, name, description, priceKr, stock, brand, featured, categorySlug, images, attributes`. WooCommerce's own export has other columns, so you reshape it into these first; the import reads no other layout.
* **One row is one simple product**, saved by its slug: a row whose slug already exists updates that product, so the same file can be imported again. A failing row is reported by its row number and the other rows are still saved. There is no dry run.
* **Under 1 MB per file.** The page hands the whole CSV to a Server Action (`app/admin/produkter/import/actions.ts`), and Next caps a Server Action's request at 1 MB unless `experimental.serverActions.bodySizeLimit` in `next.config.ts` raises it — the engine's does not. A larger file fails as a whole: an error instead of the row report, and nothing saved. HTML descriptions and image URLs from the old shop get there at a few hundred products, so split the file into parts well under 1 MB — the request carries the CSV as escaped text, a little larger than the file — each with the header line, and import them one after another.
* **Row rules:** the slug may hold only `a–z`, `0–9` and `-`; the name needs at least 2 characters and the description at least 10; `priceKr` is in kroner and must be above 0; `categorySlug` must name a category that already exists — the import never creates one; `images` is a JSON array or `|`-separated URLs; `attributes` is a JSON object.
* **Not carried:** variants, SKU, weight, SEO fields, a permalink, a category's parent.
* **Images are stored as the URLs you give**, not copied. The product card renders them through `next/image` (`components/ProductCard.tsx`), which loads a remote image only from a host listed in `images.remotePatterns` in `next.config.ts` — Unsplash, Vercel Blob and picsum as shipped. An image left in the old site's `/wp-content/uploads/` is therefore not shown, and after the domain switch that host name points at the new shop. Copy each image into the shop's storage first with the `images.import_from_url` tool (`lib/tools/images.ts`, over `POST /api/v1/tools/images.import_from_url` — [MCP tools](/docs/api/mcp-tools); JPEG, PNG or WebP up to 5 MB, needs `BLOB_READ_WRITE_TOKEN`) and put the URL it returns in the CSV. For many at once, use Google Drive folders of at most 50 files each, images only: a run of `/admin/drive` or the `drive.import_folder` tool reads at most the folder's first 50 files — the most recently modified first, with no next page, so at most the same 50 on every run while the folder's 50 most recently modified files stay the same; a file edited later, or added with a later modified time, moves into them, and past 50 files the oldest drops out — and counts a file it copied on an earlier run as imported again, so a second run on an unchanged folder of more than 50 reports the same number and adds nothing new to the media library (a copy of an image it already holds from elsewhere is still uploaded to Blob again on each run, unused). Each new JPEG, PNG or WebP up to 5 MB among them goes into the media library (`/admin/media`) on its own Blob URL (`plugins/google-workspace/lib/drive-import.ts`; the `googleDrive` and `mediaLibrary` flags and the [Google Workspace connector](/docs/features/google-workspace)). One folder per run: pass each folder's ID as `folderId` to `drive.import_folder`, or save it as the import folder (`Importmappe`) in `/admin/drive` before each run.
* **Two other product paths.** The [Google Sheets sync](/docs/features/google-sheets-sync) (`sheetsSync` flag, the Google Workspace connector; `/admin/sheets` or the `sheets.pull` tool) reads a `Products` tab with the CSV's columns plus `sku` in front and `currency` (DKK only), so it is the one path from a reshaped export that carries a `sku` (`plugins/google-workspace/lib/sheets-sync.ts`); like the CSV it never creates a category. The product scrape at `/admin/produkter/scrape` (`lib/scrape/product.ts`, needs `FIRECRAWL_API_KEY` and an AI key) turns one public product page into a product — any shop's page, a WooCommerce one too, but one page at a time and simple products only: its name, description, price, attributes and up to eight image URLs. The slug is made from the name, not taken from the old address, and the images stay on the old host, with the `next/image` limit above.

### Categories: nested, from the release after v0.62.0 [#categories-nested-from-the-release-after-v0620]

A parent category's page lists the products of every category below it, and the breadcrumb data (`BreadcrumbList`, and the visible trail where the `breadcrumbs` flag is on) walks the parent chain (`lib/category-tree.ts`). That is the shape of a WooCommerce catalogue, whose products sit in the innermost categories.

Nothing in the engine sets a parent yet — the importer that would carry the tree across is not built: the admin category form, the `categories.upsert` tool (`lib/tools/categories.ts`), the product CSV import and a sitepack restore carry no parent, so a tree exists only where something writes `Category.parentId` in the database directly. The catalogue's category filter and the `products.search` tool still match only products placed in a category directly. Details: [Customer storefront → Nested categories](/docs/features/customer-storefront#nested-categories).

### Pages and posts [#pages-and-posts]

The URL import, `content.import_site` (`lib/tools/import.ts`, behind the `siteImport` flag and `FIRECRAWL_API_KEY`), crawls a public site and lands its pages and posts as drafts to review — not products, SEO fields or redirects. Its requirements and limits are in [Choose your path → Origin](/docs/getting-started/choose-your-path).

## What is not built [#what-is-not-built]

* **A WooCommerce importer.** Nothing in the engine reads a WooCommerce export as it is, or the WooCommerce REST API. Of the paths above, only the Sheets sync carries a SKU, and none carries variants, weights, category parents or SEO titles; the sitepack restore that does carry variants, weights and SKUs reads only a Cartwright site's own export (`lib/sitepack/spec.ts`).
* **A migration screen in the admin.** No page under `app/admin/` tracks a move from start to switch.
* **Permalinks filled by an import, and served at their address** (see above).
* **Orders and customers do not move:** no import writes them. Since v0.59.0 the schema has the columns such an import would fill (`externalId` and `source` on orders and customers, and an order line that may name a product the shop no longer has), but the import that would fill them is not built. WordPress plugins are rebuilt, not migrated.

## What you do at cutover [#what-you-do-at-cutover]

1. **List every old address** — products, product categories, pages, posts — for example from the old site's XML sitemap, and choose the new address of each.
2. **Set the URL options before the new shop is public** (`trailingSlash`, `localePrefix` in `brand.config.ts`; leave `permalinks` off). Browsers keep a 308 for good: a shop that goes back from `localePrefix: "never"` sends a visitor whose browser remembers the redirect in a circle until that cache is cleared (`docs/urls.md`).
3. **Create the categories** in `/admin/kategorier` — the CSV import never creates one.
4. **Copy the product images** into the shop's storage with `images.import_from_url` (or from Drive folders of at most 50 images each with `drive.import_folder`, one folder per run), then **reshape the product export** into the ten columns and import it at `/admin/produkter/import`, in files under 1 MB. Fix the rows it reports and import again. Variants are added by hand ([Product variants](/docs/features/product-variants)).
5. **Rebuild pages and posts** — with the URL import as drafts, or by hand in `/admin/sider` and `/admin/blog`.
6. **Write the redirect CSV**: one row for every old path whose address changes, straight to its final page, the destination without a language. Run the dry run in `/admin/redirects`, fix the lines it names, then import. Leave out two kinds of address — the dry run passes them, and they never fire: query-string addresses such as `/?p=123`, and the paths that never reach your redirects with your URL options (above). Give the ones that matter a `next.config.ts` rule — with `has` and `type: "query"` for a query-string address (above) — or a host redirect.
7. **Test checkout before the switch.** WooCommerce payment plugins do not move; the engine's checkout is Stripe (`lib/stripe.ts`) — see [Go Live](/docs/deployment/go-live).
8. **Switch the domain** and set `NEXT_PUBLIC_APP_URL` ([Custom domain](/docs/deployment/custom-domain)).
9. **Check from outside**: `curl -sIL https://<your-domain>/<old path>` for a few addresses of each kind, and read every status line. The last one, and the address it answers for, is what a visitor gets. Then submit the new `/sitemap.xml` to your search console.
   * **A `200` on a page of yours** (not the login page): the address is handled — by your row, by one of the shop's own redirects (above), or because it already is a route — except at a product or category address, which answers `200` even when nothing is there. A product or category page streams (its `loading.tsx`, in `app/[locale]/product/[slug]/` and `app/[locale]/category/[slug]/`), so its `200` is sent before the page has looked the slug up: the old shop's `/product/<slug>/` whose slug was not kept, the same address under `/produkt/`, or a row pointing at a mistyped product or category ends on a `200` page that says nothing was found. `curl -sIL` prints only the headers, so for those addresses read the page: `curl -sL <url> | grep -o '<title>[^<]*'` prints `<title>Product not found` (`Produkt ikke fundet` on a Danish page) or `<title>Category not found`, and the page carries `<meta name="robots" content="noindex"/>`. Check that it is the page you meant.
   * **A `200` on the login page** (`/account/login`, with the language and final `/` your URL options give it): the address that answered with a `307` to `/account/login` was caught by a gate in `proxy.ts` — or, for a path with a dot under an admin section such as `/admin/media/<file>`, by the admin pages' own sign-in guard (`app/admin/layout.tsx` → `lib/admin.ts`) — not answered by a row; either sends a visitor without a session to sign in. A gate sees each request as the proxy gets it: with `trailingSlash` off, after Next's `308` has dropped the final `/`, and under `"always"`, again after next-intl's `307` has added the language. The **admin gate** takes `/admin`, every path below `/admin/` and every path that begins with a configured language and then `/admin` (`/en/admin…` in a new scaffold), so under `"always"` also `/administration/` with no row. The **account gate** takes `/account`, every path with `/account/` in it and every path that begins with a configured language and then `/account`, except one containing `/account/login` or `/account/signup`: in either mode WooCommerce's My Account endpoints where the old account page sat at `/account/`, such as `/account/edit-address/` (WooCommerce's default `/my-account/` is caught by neither gate), and under `"always"` also `/accounting/` with no row. A path with a dot never reaches the proxy, so never one of its gates. If the address matters, give it a row — the lookup runs before both gates — except for `/admin` and everything below it, and the seven Danish segments, whose rows never fire (above): redirect those ahead of the proxy, or — for a Danish segment — give its English path a row, which answers one hop later.
   * **A `404`**: no page at the last address. A missing product or category does not end here: its address answers `200` (above). The address has no row and no page, its row points at a page that does not exist (a row to a missing `/info/<slug>` ends here), or no row for it fires: a path in one of the seven Danish segments (a row for its English path does, one hop later), or one of the paths that never reach your redirects (above) — a path with a dot among them, which the proxy never sees, and under `"never"` a path such as `/administration/`.
   * **The hops before it.** With `trailingSlash` off (the default), an old address that ends in `/` first answers Next's own `308` to the same path without it (`next.config.ts` turns that redirect off only together with `trailingSlash`), and the hops below come after that one. The old shop's addresses carry no language, and a redirect keeps the language of the request (`lib/redirects/match.ts`), so with the default `localePrefix: "always"` a redirected address answers `301` to the new page without a language, then next-intl's `307` to that page in a language — the shop's `defaultLocale` for a request like curl's that asks for none (`/en/…` in a shop from `npx create-cartwright`, which starts English-only), or a language the browser asks for that the shop has — and `200` there (`proxy.ts`). The `307` is temporary because the language depends on who asks. An old address that is already a route and has no row of its own, such as the old shop's `/product/<slug>/`, takes the same hops without the `301`. On a one-language shop with `localePrefix: "never"` there is no language hop: a redirected address is `301` then `200`, and a route answers `200` without a `301` — there, with `trailingSlash` on, a route asked for without the `/` takes the proxy's `308` to the slash form first. With `trailingSlash` on, a redirected address answers its `301` first in either form: the lookup reads `/x` and `/x/` as one address (`lib/redirects/match.ts`) and runs before the proxy adds a slash. Under `"always"` a row whose source only begins with `/admin`, such as `/administration/` or `/admin-old/`, is the exception, with `trailingSlash` on or off: the lookup skips that address's first request (above), so the row fires one hop later, on the request that carries the language — next-intl's `307` comes before your `301`, and the `301` goes to the new page already in that language.
10. **Keep an export of the old WordPress database.** Orders and customers do not move — their import is not built — so it stays their only record.

## Other platforms [#other-platforms]

* **WordPress without WooCommerce** — no WordPress importer: pages and posts by the URL import as drafts, old addresses by the redirect CSV above.
* **Shopify** — Hoptify, in `--profile full` only (`plugins/hoptify/`): the palette and the products from product URLs you list; no pages or variants, and nothing uses the Shopify Admin API ([Choose your path](/docs/getting-started/choose-your-path)).
* **Squarespace** — no Squarespace importer: pages and posts by the URL import, simple products by the product CSV, old addresses by the redirect CSV.
* **Wix** — no Wix importer: pages and posts by the URL import, simple products by the product CSV, old addresses by the redirect CSV.
