cartwright
Migration

Moving from WooCommerce

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.

This is a status page, not an importer manual: 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

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:

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

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. 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.

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

/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; 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 only the folder's first 50 files — the most recently modified first, with no next page, so the same 50 on every run — and counts a file it copied on an earlier run as imported again, so a second run on a fuller folder reports the same number and copies nothing new. Each 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). 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 (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

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.

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.

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

  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).
  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.
  8. Switch the domain and set NEXT_PUBLIC_APP_URL (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. 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. A redirected address that begins with /admin without being /admin or below it, such as /administration/, has the two the other way round: the 307, then your 301 (above). An old address that is already a route, and has no row of its own, gets no 301: with trailingSlash off, the old shop's /product/<slug>/ answers the 308, then the 307, then 200, and the same address without the / answers the 307 alone; with it on, both answer the 307 alone. On a one-language shop with localePrefix: "never", a redirected address is 301 then 200, and one that is already a route answers 200 directly; with trailingSlash off, an address ending in / has the 308 in front of each; with it on, none does. A 404 at the end is an address with no row and no page yet, or one the proxy never sees (above). Submit the new /sitemap.xml to your search console.
  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

  • 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).
  • 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.

On this page