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 columnsfrom,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 theredirects.importtool (lib/tools/redirects.ts):dryRun: truereturns the plan — every row that would be saved, with its line, and every error — andconfirm: trueapplies it, scopesettings:write. - The rules (
lib/redirects/import.ts): at most 2,000 rows and 256 KB per file; status301(the default) or302. 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/daor/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-stolfor 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-stolitself never matches. - Seven Danish segments answer first. Before it looks at your redirects, the proxy sends
/produkt/…,/kategori/…,/kurv,/konto,/kontakt,/ordreand/anmeld, with or without a configured language in front, to their English twin with a 301 (LEGACY_SLUG_MAPinproxy.ts). So/produkt/<slug>/reaches the product without a redirect row: at/product/<slug>/withtrailingSlashon, and at/product/<slug>with it off, since Next's own308drops the slash first. With the defaultlocalePrefix: "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/redirectscannot 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 anext.config.tsrule 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.phpfile, an old XML sitemap such as/sitemap_index.xml, a permalink ending in.html— which the proxy'smatcherleaves out, so Next answers it on its own, with a404unless the new shop has a file or route at that address; a path that begins with/hero—/heroes/,/hero-sale/— which the samematcherleaves out by name, as it doesfavicon.ico,opengraph-image,twitter-image,apple-icon,_next/staticand_next/image; a path that begins with/api—/apiary/as well, since that check has no slash after it —/adminand everything below/admin/, and/oauthand everything below it (lib/locale-exempt.ts); and, underlocalePrefix: "never", three more:/om-os, whichnext.config.tssends to/aboutbefore the proxy runs;/info/about,/info/om-os,/info/contactand/info/privacy, which the proxy sends to/about,/contactand/privacyfirst (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/admincheck 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's307adds 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 answer404, and you submit the new/sitemap.xmlin their place (step 9). For an address that still matters, such as an image other sites link to, redirect it ahead of the proxy: inredirects()in the shop's ownnext.config.ts, which Next runs before the proxy whatever itsmatcherleaves out — the engine sends/favicon.icoto/iconthat 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:
// 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: truemakes 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, byGETorHEAD, answers with a permanent 308 to the slash form./api,/admin,/oauth,/icon,/og,/.well-knownand files answer with or without it.localePrefix: "never"drops the language segment —/en/in a shop fromnpx 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 onlya–z,0–9and-, so a slug withæ,øorågets a new one and its old address a row. With the defaultlocalePrefix: "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
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 unlessexperimental.serverActions.bodySizeLimitinnext.config.tsraises 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–9and-; the name needs at least 2 characters and the description at least 10;priceKris in kroner and must be above 0;categorySlugmust name a category that already exists — the import never creates one;imagesis a JSON array or|-separated URLs;attributesis 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 inimages.remotePatternsinnext.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 theimages.import_from_urltool (lib/tools/images.ts, overPOST /api/v1/tools/images.import_from_url— MCP tools; JPEG, PNG or WebP up to 5 MB, needsBLOB_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/driveor thedrive.import_foldertool 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; thegoogleDriveandmediaLibraryflags and the Google Workspace connector). One folder per run: pass each folder's ID asfolderIdtodrive.import_folder, or save it as the import folder (Importmappe) in/admin/drivebefore each run. - Two other product paths. The Google Sheets sync (
sheetsSyncflag, the Google Workspace connector;/admin/sheetsor thesheets.pulltool) reads aProductstab with the CSV's columns plusskuin front andcurrency(DKK only), so it is the one path from a reshaped export that carries asku(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, needsFIRECRAWL_API_KEYand 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 thenext/imagelimit 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 (
externalIdandsourceon 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
- 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.
- Set the URL options before the new shop is public (
trailingSlash,localePrefixinbrand.config.ts; leavepermalinksoff). Browsers keep a 308 for good: a shop that goes back fromlocalePrefix: "never"sends a visitor whose browser remembers the redirect in a circle until that cache is cleared (docs/urls.md). - Create the categories in
/admin/kategorier— the CSV import never creates one. - Copy the product images into the shop's storage with
images.import_from_url(or from Drive folders of at most 50 images each withdrive.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). - Rebuild pages and posts — with the URL import as drafts, or by hand in
/admin/siderand/admin/blog. - 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 anext.config.tsrule — withhasandtype: "query"for a query-string address (above) — or a host redirect. - Test checkout before the switch. WooCommerce payment plugins do not move; the engine's checkout is Stripe (
lib/stripe.ts) — see Go Live. - Switch the domain and set
NEXT_PUBLIC_APP_URL(Custom domain). - Check from outside:
curl -sIL https://<your-domain>/<old path>for a few addresses of each kind, and read every status line. WithtrailingSlashoff (the default), an old address that ends in/first answers Next's own308to the same path without it (next.config.tsturns that redirect off only together withtrailingSlash), 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 defaultlocalePrefix: "always"a redirected address answers301to the new page without a language, then next-intl's307to that page in a language — the shop'sdefaultLocalefor a request like curl's that asks for none (/en/…in a shop fromnpx create-cartwright, which starts English-only), or a language the browser asks for that the shop has — and200there (proxy.ts). The307is temporary because the language depends on who asks. A redirected address that begins with/adminwithout being/adminor below it, such as/administration/, has the two the other way round: the307, then your301(above). An old address that is already a route, and has no row of its own, gets no301: withtrailingSlashoff, the old shop's/product/<slug>/answers the308, then the307, then200, and the same address without the/answers the307alone; with it on, both answer the307alone. On a one-language shop withlocalePrefix: "never", a redirected address is301then200, and one that is already a route answers200directly; withtrailingSlashoff, an address ending in/has the308in front of each; with it on, none does. A404at the end is an address with no row and no page yet, or one the proxy never sees (above). Submit the new/sitemap.xmlto your search console. - 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 fullonly (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.