← Blog

WordPress

WooCommerce to Medusa: an eight-week parallel-run cutover

The warehouse manager in Nijmegen ran his Friday stock report at 16:42, and WooCommerce timed out for the fourth time that week. We had eight weeks.

Jacob Molkenboer· Founder · A Brand New Company· 20 Apr 2025· 9 min
Craft-paper parcel with linen twine, brass tag, leather ledger, green ribbon, red wax seal on ivory paper.

The warehouse manager in Nijmegen ran his Friday stock report at 16:42, and WooCommerce timed out for the fourth time that week. The wp_postmeta table had crossed 41 million rows. Each of the 11,400 SKUs carried between 18 and 60 custom attributes (beam angle, IP rating, CRI, dimming protocol, fixture mount, finish, optic, and so on), most of them stored as serialized PHP in attribute taxonomy term meta. The shop ran on WordPress 5.4 and WooCommerce 4.9, last touched in 2020. Nineteen B2B dealer portals authenticated against it via a forked SAML plugin whose original author had archived the repo.

The CEO wanted off it before peak season started in week 40. We had eight weeks. This is the playbook we used. It worked.

Why the existing stack had run out of road

WordPress 5.4 went out of security support in late 2022. WooCommerce 4.9 was three major versions behind. The dealer SSO plugin (wp-saml-auth, modified) was pinned to a Guzzle release with a known CVE. Upgrading any one of these in isolation broke the others. We had tried, twice, on a staging clone. The serialized-PHP attribute soup made every wp-cli upgrade run for ninety minutes and leave four-figure orphan rows behind.

The numbers that mattered to the business were short and specific. 11,400 active SKUs. 19 live dealer portals consuming /wp-json/wc/v3. 38,000 historical orders that had to survive the move. Four warehouse operators logged in at all times. The Friday stock report: target eight seconds, actual four to eleven minutes.

The stack we picked, and why

Three constraints drove the choice. We needed a headless commerce engine that spoke a clean API the dealer portals could move to. We needed search that handled 11,400 SKUs with thirty-plus filterable facets in under 100ms. And we needed a frontend the in-house marketing lead (no engineer) could edit without us in the room.

Medusa.js gave us the commerce engine. It is a Node.js commerce framework with a sensible product model that maps cleanly onto how lighting wholesalers actually think (products, variants, options, prices per customer group). The B2B module covers dealer pricing and quote workflows natively in v2, which removed an entire WooCommerce plugin (B2BKing) from the stack.

Meilisearch handled the search. We chose it over Algolia (cost at this catalog size) and Elasticsearch (operational weight for a 24-person company). The 11,400 SKUs with facets indexed in nine seconds and served filtered queries in twelve to forty milliseconds.

Next.js carried the storefront. The marketing lead got a Sanity-backed editor for landing pages. ISR handled the long tail of product pages without forcing a rebuild of 11,400 statics on every deploy.

Week 0: the audit nobody wants to do

The first week, before any code, we made a flat CSV of every custom field, every serialized attribute, and every dealer-portal endpoint actually being called. Not the ones documented. The ones actually being called. We ran a fourteen-day request log on a sidecar Nginx in front of WordPress and grepped it.

What we found: seven of the nineteen dealer portals used endpoints WooCommerce had marked deprecated. Three portals hit a custom /wp-admin/admin-ajax.php handler nobody on the team remembered building. One portal sent SOAP. The audit added two days to the timeline and saved at least two weeks. If you skip this step you will discover the SOAP integration in week seven.

Warning

Document what is actually called, not what the team thinks is called. A fourteen-day request log will surface integrations the original developer has forgotten about. Plan for at least one of them to be undocumented.

Weeks 1 and 2: the read replica and the shadow catalog

We did not touch the production database. We set up a read replica of the MySQL primary and pointed a Medusa import worker at it. The worker ran every fifteen minutes:

// medusa/src/jobs/woo-sync.ts
import { ScheduledJobConfig, ScheduledJobArgs } from "@medusajs/framework"

export default async function wooSync({ container }: ScheduledJobArgs) {
  const woo = container.resolve("wooReadReplica")
  const products = container.resolve("product")

  const changed = await woo.query(`
    SELECT p.ID, p.post_modified
    FROM wp_posts p
    WHERE p.post_type IN ('product', 'product_variation')
      AND p.post_modified > ?
  `, [await lastSyncCursor()])

  for (const row of changed) {
    const raw = await loadWooProduct(row.ID)   // joins postmeta, taxonomy, attributes
    const mapped = mapToMedusa(raw)            // the real work lives here
    await products.upsert(mapped)
  }

  await advanceCursor()
}

export const config: ScheduledJobConfig = {
  name: "woo-sync",
  schedule: "*/15 * * * *",
}

The mapToMedusa function was where every domain decision lived. We rebuilt the attribute model from scratch (typed product options, not serialized strings) and wrote a migration table that mapped every legacy attribute term to a Medusa option value. The lighting team sat with us for two afternoons to decide which attributes were product-level (CRI, beam angle) and which were variant-level (finish, length). They had never been asked. They had been guessing.

By the end of week two the Medusa store had a current, queryable copy of the catalog. WordPress was still the source of truth. Nothing customer-facing had moved.

Weeks 3 and 4: the catalog reshape

This is the work nobody wants to scope and everyone underestimates. 11,400 SKUs entered by four different people over six years carry every form of inconsistency you can imagine. "IP44" and "IP 44" and "Indoor". "3000K" and "3.000K" and "warm white". We wrote a normaliser per attribute and ran it against the Medusa copy, not against WordPress. We never wrote back to WordPress. That detail matters. The legacy system stayed frozen so we could compare against it at cutover.

// medusa/src/lib/normalise/ip-rating.ts
const IP_PATTERN = /\bIP\s?(\d{2})\b/i

export function normaliseIpRating(raw: string | null): string | null {
  if (!raw) return null
  const match = raw.match(IP_PATTERN)
  if (match) return `IP${match[1]}`
  if (/indoor|binnen/i.test(raw)) return "IP20"
  if (/wet|nat/i.test(raw)) return "IP65"
  return null   // flag for human review
}

Anything the normaliser returned null for went into a Postgres attribute_review table linked to the product. The lighting team got a tiny Next.js admin page that surfaced one ambiguous SKU at a time with a dropdown of accepted values. They cleared 612 ambiguous attributes in four afternoons. We did not let one engineer guess at lighting-domain decisions.

By the end of week four, Meilisearch had a clean index and the storefront had filterable category pages. Both still ran on a staging subdomain.

Weeks 5 and 6: the SSO bridge

The nineteen dealer portals were the hard part. Each had its own integration. Three used SAML against the wp-saml-auth plugin. Eight used Basic Auth with API keys against /wp-json/wc/v3. Five used OAuth1 (the WooCommerce default). Two used the legacy admin-ajax custom endpoint. One used SOAP.

We did not migrate them. We bridged them.

We built a thin Node service (Fastify, around 600 lines) sitting between the dealers and Medusa. It speaks all five protocols on the dealer side and translates to Medusa's Admin and Store APIs on the back. Each dealer kept their existing credentials and their existing endpoint URLs. The bridge ran first against WordPress (verifying response parity) and then against Medusa (in week seven) without a single dealer noticing.

// bridge/src/routes/wc-v3-products.ts
fastify.get("/wp-json/wc/v3/products", async (req, reply) => {
  const auth = await verifyOAuth1(req)          // legacy WooCommerce credential
  const query = mapWcQueryToMedusa(req.query)
  const result = await medusa.store.products.list(query, {
    headers: { "x-publishable-api-key": dealerKey(auth.dealerId) },
  })
  return reply.send(mapMedusaToWcResponseShape(result))   // bit-for-bit identical
})

Response parity was the whole game. We ran every bridge endpoint against both backends for a week and diffed the JSON. Whenever the diff was not empty, the bridge lost. Either we fixed the mapper or we filed it as a deliberate, documented change and notified the dealer.

Two of the nineteen dealers had been silently relying on a field WooCommerce returned by accident. We caught it in the diff, asked them, and shipped a compatibility shim.

Internal cutover postmortem, 2026

The SOAP integration we wrapped in a small endpoint that translated to Medusa REST. It was ugly. It worked. The dealer was happy.

Week 7: the parallel run

This is the week most migrations skip and most migrations regret.

For seven days, every dealer request hit both backends. WordPress served the response. Medusa logged what it would have returned. A nightly job diffed the two streams and posted to a Slack channel nobody muted. We caught 41 mismatches in the first 48 hours. By day six the rate was zero for three days running.

In parallel, the warehouse team ran their daily flow against the Medusa admin (the new one) and against WooCommerce (the old one). They picked WooCommerce for production. They reported issues against Medusa. We fixed them. By Friday they were asking if they could just stop using WooCommerce already.

Week 8: cutover

Cutover was a Saturday morning. Three steps.

At 06:00 Amsterdam, WordPress went read-only via a small mu-plugin that returned 503 on any write endpoint. At 06:05, the last fifteen minutes of WordPress writes (orders mostly) replayed through the import worker into Medusa. At 06:20, the dealer bridge flipped its upstream from WordPress to Medusa, and DNS for the storefront moved to the Next.js Vercel deployment.

At 06:32 a dealer in Köln placed the first order against the new stack. The Friday stock report, the one that started this story, ran in 1.4 seconds.

We kept WordPress online, read-only, for ninety days. Nobody asked for it.

What we would do differently

Two things. First, we underestimated how much of the catalog cleanup belonged to the client, not to us. The lighting team was the only group on the planet who could decide whether IP20 should be the default when an attribute was missing. Get them in the room in week one, not week three.

Second, the SOAP integration. We should have asked the dealer to move to REST during the migration window, when they were already paying attention. Wrapping SOAP indefinitely is a future bill for someone.

What to do this afternoon if your stack looks like this

Open your wp_postmeta table and run SELECT COUNT(*) FROM wp_postmeta WHERE meta_key LIKE '_product_attributes'. If the answer is north of five figures and you are on WooCommerce 4 or 5, your Friday is already running on borrowed time. The audit is the first move. Everything else follows from it.

When we built the dealer-portal bridge for this Nijmegen wholesaler, the lesson that stuck was that you migrate the data once but you bridge the integrations until the dealers want to move. If you have a similar shape (old WordPress shop, dozens of dealer or partner integrations, a catalog nobody fully understands), that bridge-first sequence is how we plan a legacy migration end to end.

Key takeaway

You migrate the catalog once, but you bridge the integrations until the dealers want to move. Plan for the bridge, not just the data.

FAQ

Why Medusa.js instead of Shopify or BigCommerce?

Medusa is self-hosted and source-available, so dealer pricing logic, B2B quote flows, and the bridge to legacy SSO live in the same codebase. SaaS platforms force that logic into apps or workarounds at this scale.

Why not just upgrade WordPress and WooCommerce in place?

We tried twice on a staging clone. The serialized-PHP attribute soup, an archived SAML plugin, and a Guzzle CVE made every upgrade leave orphan rows and break a dealer integration. The cost of fixing was higher than the cost of moving.

Can the parallel run be shorter than a week?

Sometimes. If you have fewer than five integrations and a clean catalog, three days is enough. With nineteen dealer portals and a six-year catalog, seven days is the minimum we will plan.

What does the SSO bridge cost to maintain after cutover?

A few hours per month while dealers gradually move off legacy protocols. We aim to deprecate the bridge inside eighteen months and write that timeline into the project plan from day one.

Does Meilisearch hold up past 11,400 SKUs?

It scales well into the low millions on a single node, in our experience. Past that, sharding or moving to OpenSearch becomes the right conversation. For most B2B catalogs, Meilisearch is enough.

wordpressmigrationlegacy sitese-commercearchitectureintegrations

Building something?

Start a project