WordPress
Van WooCommerce naar Medusa: parallelle cutover in 8 weken
De magazijnchef in Nijmegen draaide zijn vrijdagrapport om 16:42 uur, en WooCommerce gaf voor de vierde keer die week een time-out. We hadden acht weken.

De magazijnchef in Nijmegen draaide zijn vrijdagse voorraadrapport om 16:42, en WooCommerce gaf voor de vierde keer die week een time-out. De wp_postmeta-tabel was de 41 miljoen rijen voorbij. Elk van de 11.400 SKU's droeg tussen de 18 en 60 custom attributen (beam angle, IP-rating, CRI, dimprotocol, montagewijze, afwerking, optiek, enzovoort), de meeste opgeslagen als serialized PHP in attribute taxonomy term meta. De shop draaide op WordPress 5.4 en WooCommerce 4.9, voor het laatst aangeraakt in 2020. Negentien B2B-dealerportals authenticeerden er tegenaan via een geforkte SAML-plugin waarvan de oorspronkelijke auteur de repo had gearchiveerd.
De CEO wilde eraf voordat het hoogseizoen begon in week 40. We hadden acht weken. Dit is het playbook dat we gebruikten. Het werkte.
Waarom de bestaande stack op was
WordPress 5.4 viel eind 2022 uit de security-support. WooCommerce 4.9 liep drie major versies achter. De dealer-SSO-plugin (wp-saml-auth, aangepast) zat vast op een Guzzle-release met een bekende CVE. Eén van deze in isolatie upgraden brak de anderen. We hadden het twee keer geprobeerd op een staging-kloon. De serialized-PHP attribuutsoep zorgde dat elke wp-cli-upgrade negentig minuten draaide en daarna vier cijfers aan orphan-rijen achterliet.
De cijfers die er voor het bedrijf toe deden waren kort en specifiek. 11.400 actieve SKU's. 19 live dealerportals die /wp-json/wc/v3 aanriepen. 38.000 historische orders die de overstap moesten overleven. Vier magazijnmedewerkers altijd ingelogd. Het vrijdagse voorraadrapport: doel acht seconden, werkelijk vier tot elf minuten.
De stack die we kozen, en waarom
Drie randvoorwaarden bepaalden de keuze. We hadden een headless commerce-engine nodig die een schone API sprak waar de dealerportals naartoe konden migreren. We hadden search nodig die 11.400 SKU's met dertig-plus filterbare facetten onder de 100ms aankon. En we hadden een frontend nodig die de interne marketinglead (geen engineer) zonder ons in de kamer kon bewerken.
Medusa.js leverde de commerce-engine. Het is een Node.js commerce-framework met een verstandig productmodel dat netjes aansluit op hoe verlichtingsgroothandels echt denken (producten, varianten, opties, prijzen per klantgroep). De B2B-module dekt dealerprijzen en quote-workflows native in v2, wat een hele WooCommerce-plugin (B2BKing) uit de stack haalde.
Meilisearch verzorgde de search. We kozen het boven Algolia (kosten bij deze catalogusgrootte) en Elasticsearch (operationeel gewicht voor een bedrijf van 24 mensen). De 11.400 SKU's met facetten indexeerden in negen seconden en serveerden filterqueries in twaalf tot veertig milliseconden.
Next.js droeg de storefront. De marketinglead kreeg een Sanity-gestuurde editor voor landingspagina's. ISR handelde de longtail van productpagina's af zonder bij elke deploy een rebuild van 11.400 statics af te dwingen.
Week 0: de audit die niemand wil doen
De eerste week, voordat er code geschreven werd, maakten we een platte CSV van elk custom veld, elk serialized attribuut en elk dealerportaal-endpoint dat daadwerkelijk werd aangeroepen. Niet de gedocumenteerde. De daadwerkelijk aangeroepen endpoints. We draaiden veertien dagen lang een request-log op een sidecar-Nginx vóór WordPress en greppen erdoorheen.
Wat we vonden: zeven van de negentien dealerportals gebruikten endpoints die WooCommerce als deprecated had gemarkeerd. Drie portals raakten een custom /wp-admin/admin-ajax.php-handler waarvan niemand in het team zich herinnerde hem te hebben gebouwd. Eén portal stuurde SOAP. De audit voegde twee dagen toe aan de planning en bespaarde minstens twee weken. Als je deze stap overslaat, ontdek je de SOAP-integratie in week zeven.
Documenteer wat daadwerkelijk wordt aangeroepen, niet wat het team denkt dat wordt aangeroepen. Een veertiendaagse request-log brengt integraties boven water die de oorspronkelijke ontwikkelaar is vergeten. Reken erop dat minstens één ervan ongedocumenteerd is.
Week 1 en 2: de read replica en de schaduwcatalogus
We raakten de productiedatabase niet aan. We zetten een read replica op van de MySQL-primary en richtten een Medusa-importworker erop. De worker draaide elke vijftien minuten:
// 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) // het echte werk zit hier
await products.upsert(mapped)
}
await advanceCursor()
}
export const config: ScheduledJobConfig = {
name: "woo-sync",
schedule: "*/15 * * * *",
}
De mapToMedusa-functie was waar elke domeinbeslissing landde. We bouwden het attributenmodel van scratch opnieuw op (getypte productopties, geen serialized strings) en schreven een migratietabel die elke legacy attribute-term koppelde aan een Medusa option value. Het verlichtingsteam zat twee middagen met ons om te beslissen welke attributen op productniveau zaten (CRI, beam angle) en welke op variantniveau (afwerking, lengte). Dat was hen nooit eerder gevraagd. Ze hadden zitten gokken.
Aan het einde van week twee had de Medusa-store een actuele, doorzoekbare kopie van de catalogus. WordPress was nog steeds de source of truth. Niets klantgerichts was verplaatst.
Week 3 en 4: de catalogus herstructureren
Dit is het werk dat niemand wil scopen en iedereen onderschat. 11.400 SKU's, ingevoerd door vier verschillende mensen over zes jaar, dragen elke vorm van inconsistentie die je kunt bedenken. "IP44" en "IP 44" en "Indoor". "3000K" en "3.000K" en "warm wit". We schreven een normaliser per attribuut en draaiden die tegen de Medusa-kopie, niet tegen WordPress. We schreven nooit terug naar WordPress. Dat detail telt. Het legacy systeem bleef bevroren, zodat we het bij de cutover ertegen konden vergelijken.
// 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 // markeer voor menselijke review
}
Alles waar de normaliser null voor teruggaf, ging in een Postgres-tabel attribute_review die gekoppeld was aan het product. Het verlichtingsteam kreeg een klein Next.js-adminpagina dat één dubbelzinnige SKU tegelijk toonde, met een dropdown van toegestane waardes. Ze ruimden in vier middagen 612 dubbelzinnige attributen op. We lieten geen engineer gokken bij verlichtingsdomein-beslissingen.
Aan het einde van week vier had Meilisearch een schone index en had de storefront filterbare categoriepagina's. Beide draaiden nog op een staging-subdomein.
Week 5 en 6: de SSO-bridge
De negentien dealerportals waren het lastige deel. Elk had zijn eigen integratie. Drie gebruikten SAML tegen de wp-saml-auth-plugin. Acht gebruikten Basic Auth met API-keys tegen /wp-json/wc/v3. Vijf gebruikten OAuth1 (de WooCommerce-default). Twee gebruikten het legacy admin-ajax custom endpoint. Eén gebruikte SOAP.
We migreerden ze niet. We bridgeden ze.
We bouwden een dunne Node-service (Fastify, zo'n 600 regels) die tussen de dealers en Medusa zat. Hij sprak alle vijf protocollen aan de dealerkant en vertaalde naar Medusa's Admin- en Store-API's aan de achterkant. Elke dealer behield zijn bestaande credentials en zijn bestaande endpoint-URL's. De bridge draaide eerst tegen WordPress (response-pariteit verifiëren) en daarna tegen Medusa (in week zeven), zonder dat één dealer iets merkte.
// 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-voor-bit identiek
})
Response-pariteit was het hele spel. We draaiden elke bridge-endpoint een week lang tegen beide backends en diffden de JSON. Wanneer de diff niet leeg was, verloor de bridge. Of we fixten de mapper, of we legden het vast als bewuste, gedocumenteerde wijziging en stelden de dealer op de hoogte.
Twee van de negentien dealers steunden stilletjes op een veld dat WooCommerce per ongeluk teruggaf. We vingen het in de diff, vroegen het ze, en shipten een compatibility-shim.
Intern cutover-postmortem, 2026
De SOAP-integratie wikkelden we in een klein endpoint dat naar Medusa REST vertaalde. Het was lelijk. Het werkte. De dealer was blij.
Week 7: de parallel-run
Dit is de week die de meeste migraties overslaan en de meeste migraties later betreuren.
Zeven dagen lang ging elk dealer-request naar beide backends. WordPress serveerde de response. Medusa logde wat hij zou hebben teruggegeven. Een nachtelijke job diffte de twee streams en postte naar een Slack-kanaal dat niemand op mute had. We vingen in de eerste 48 uur 41 mismatches. Op dag zes was het percentage drie dagen op rij nul.
Parallel daaraan draaide het magazijnteam zijn dagelijkse flow tegen de Medusa-admin (de nieuwe) en tegen WooCommerce (de oude). Ze kozen WooCommerce voor productie. Ze meldden issues tegen Medusa. Wij fixten ze. Tegen vrijdag vroegen ze of ze WooCommerce niet gewoon mochten stoppen met gebruiken.
Week 8: cutover
De cutover was een zaterdagochtend. Drie stappen.
Om 06:00 Amsterdam ging WordPress read-only via een kleine mu-plugin die op elk write-endpoint een 503 teruggaf. Om 06:05 werden de laatste vijftien minuten aan WordPress-writes (vooral orders) via de import-worker afgespeeld in Medusa. Om 06:20 draaide de dealer-bridge zijn upstream van WordPress naar Medusa, en de DNS voor de storefront verhuisde naar de Next.js Vercel-deployment.
Om 06:32 plaatste een dealer in Keulen de eerste order tegen de nieuwe stack. Het vrijdagse voorraadrapport, het rapport waarmee dit verhaal begon, draaide in 1,4 seconden.
We hielden WordPress nog negentig dagen online, read-only. Niemand vroeg ernaar.
Wat we anders zouden doen
Twee dingen. Eerst onderschatten we hoeveel van de catalogus-opschoning bij de klant hoorde, niet bij ons. Het verlichtingsteam was de enige groep ter wereld die kon beslissen of IP20 de default moest zijn wanneer een attribuut ontbrak. Zet ze in week één in de kamer, niet in week drie.
Twee: de SOAP-integratie. We hadden de dealer moeten vragen om tijdens de migratie-window naar REST over te stappen, toen ze toch al opletten. SOAP eindeloos wrappen is een toekomstige rekening voor iemand.
Wat je vanmiddag kunt doen als jouw stack hierop lijkt
Open je wp_postmeta-tabel en draai SELECT COUNT(*) FROM wp_postmeta WHERE meta_key LIKE '_product_attributes'. Als het antwoord boven de vijf cijfers ligt en je zit op WooCommerce 4 of 5, draait jouw vrijdag al op geleende tijd. De audit is de eerste zet. Al het andere volgt daaruit.
Toen we de dealerportaal-bridge bouwden voor deze Nijmeegse groothandel, was de les die bleef hangen dat je de data één keer migreert, maar de integraties bridge totdat de dealers zelf willen verhuizen. Heb jij een soortgelijke vorm (een oude WordPress-shop, tientallen dealer- of partner-integraties, een catalogus die niemand helemaal doorgrondt), dan plannen we vanuit die bridge-first volgorde een legacy migratie end-to-end.
Kern
Je migreert de catalogus één keer, maar je bridget de integraties totdat de dealers zelf willen verhuizen. Plan voor de bridge, niet alleen voor de data.
FAQ
Waarom Medusa.js in plaats van Shopify of BigCommerce?
Medusa is self-hosted en source-available, dus dealerprijslogica, B2B-quote-flows en de bridge naar legacy SSO zitten in dezelfde codebase. SaaS-platforms duwen die logica bij deze schaal in apps of workarounds.
Waarom niet gewoon WordPress en WooCommerce in place upgraden?
We hebben het twee keer geprobeerd op een staging-kloon. De serialized-PHP attribuutsoep, een gearchiveerde SAML-plugin en een Guzzle-CVE zorgden dat elke upgrade orphan-rijen achterliet en een dealer-integratie brak. De kosten van fixen waren hoger dan de kosten van verhuizen.
Kan de parallel-run korter dan een week?
Soms. Als je minder dan vijf integraties hebt en een schone catalogus, zijn drie dagen genoeg. Met negentien dealerportals en een catalogus van zes jaar oud is zeven dagen het minimum dat wij plannen.
Wat kost het onderhoud van de SSO-bridge na de cutover?
Een paar uur per maand, terwijl dealers geleidelijk van legacy-protocollen af stappen. We mikken erop de bridge binnen achttien maanden uit te faseren, en zetten die timeline vanaf dag één in het projectplan.
Houdt Meilisearch het vol voorbij de 11.400 SKU's?
In onze ervaring schaalt het prima door tot in de lage miljoenen op één node. Daarboven wordt sharding of overstappen naar OpenSearch het juiste gesprek. Voor de meeste B2B-catalogi is Meilisearch genoeg.