← Blog

Joomla

Joomla to Shopify Hydrogen: a six-week shadow cutover

The Antwerp coffee importer's Joomla cart locked for eleven minutes every Friday at 16:00. Here is the six-week shadow cutover we used to swap the stack.

Jacob Molkenboer· Founder · A Brand New Company· 17 Jun 2026· 9 min
Leather shipping logbook, brass key on cream card, iron tag with green thread, wax seal fragment on ivory paper.

It is a Friday at 16:00 in Antwerpen and a 29-person specialty-coffee importer's webshop has just locked up again. The Exact Online inkoopsync fires every weekday on the hour, the VirtueMart cart tables go read-only for eleven minutes, and an order from a roaster in Ghent times out at checkout. The shop has 3,800 herkomst-pagina's, all of them still ranking, and a per-batch cupping-score history that customers actually use to decide which Yirgacheffe lot to buy. Nothing on the site is broken enough to throw out. All of it is too brittle to keep running on Joomla 3.9 and VirtueMart 3.4 in 2026.

This is the playbook we used to move them onto Shopify Hydrogen and Sanity over six weeks of shadow-traffic cutover, without losing a single ranking page or a single batch record.

Why we stopped patching

Joomla 3.x reached end-of-life in August 2023, as the project announced on the Joomla 3.10 stable release page. VirtueMart 3.4 is technically still alive but the release cadence has dropped to two patches a year and the PHP 7.4 stack underneath it is two majors behind. The client had been on a maintenance contract with another agency since 2021. Their reason for switching: the agency wanted to do a Joomla 4 upgrade. The CFO wanted to know why they should pay €18k to land on a version that EOLs in 2028.

We audited the site over a week. The findings were boring and decisive:

  • The cart-lock during the Exact sync was a MySQL transaction holding row-level locks on jos_virtuemart_orders while the sync wrote to jos_virtuemart_product_prices.
  • The 3,800 herkomst-pagina's were K2 articles with seventeen custom fields each, stored as serialized PHP in a single extra_fields column.
  • The cupping-score history was stored in a custom VirtueMart attribute table by a developer who had left in 2019. There was no documentation.
  • The Exact Online sync was a PHP cron script that re-authenticated with a long-lived password and used Exact's deprecated REST v1 endpoints.

A Joomla 4 upgrade would fix one of these. None of the others.

The stack we landed on

We picked Shopify Hydrogen for the storefront, Shopify for products and checkout, and Sanity for the content backbone. The split is the important part. Shopify owns transactions, inventory, payments, and tax. Sanity owns the 3,800 herkomst-pagina's, the cupping-score history, the photo libraries from origin trips, and the editorial copy. Each origin in Sanity holds references to the Shopify products that came from it. Each batch in Shopify carries a Sanity reference back to its origin.

The reason for Sanity rather than Shopify metaobjects: the editorial team writes long-form. Origin pages run 800 to 1,400 words, with image galleries and embedded tasting maps. Shopify's CMS is fine for product copy. It is painful for what these pages actually are, which is travel journalism.

The rule we have come back to on every editorial-plus-commerce migration since: if your content is editorial and your commerce is transactional, split them at the data layer. Do not make one CMS pretend to be the other.

Modeling 3,800 herkomst-pagina's in Sanity

The Joomla content was a K2 article with seventeen custom fields. We turned it into a Sanity document with a clean schema:

// sanity/schemas/origin.ts
export default {
  name: 'origin',
  type: 'document',
  fields: [
    { name: 'title', type: 'string' },
    { name: 'slug', type: 'slug', options: { source: 'title' } },
    { name: 'country', type: 'string' },
    { name: 'region', type: 'string' },
    { name: 'farm', type: 'string' },
    { name: 'altitude_m', type: 'number' },
    { name: 'process', type: 'string' },
    { name: 'varietals', type: 'array', of: [{ type: 'string' }] },
    { name: 'hero', type: 'image' },
    { name: 'body', type: 'array', of: [{ type: 'block' }, { type: 'image' }] },
    { name: 'shopify_products', type: 'array', of: [{ type: 'reference', to: [{ type: 'product' }] }] },
    { name: 'legacy_url', type: 'string' }, // original Joomla URL, kept for redirect mapping
  ],
}

The migration ran in three passes. Pass one: a Python script hit each Joomla page through the SEF URL, parsed the K2 HTML with BeautifulSoup, and dumped JSON. Pass two: a Node script transformed the JSON into Sanity's import format, mapped the custom fields, and uploaded images to Sanity's CDN. Pass three: an editor reviewed every page in batches of 100 over four days, flagged broken galleries, and fixed the 184 pages where the Joomla editor had pasted Word HTML.

URL preservation was non-negotiable. Joomla served the pages at /herkomst/colombia-huila-finca-tamana. Hydrogen serves them at the same path. We mapped the 47 herkomst-pagina's that had URL drift between 2015 and 2022 (the client switched SEF plugins twice) into a redirect table in Cloudflare. Every legacy URL still resolves.

Cupping scores as a metafield problem

Each batch the client imports gets scored on the SCA cupping protocol: fragrance, flavor, aftertaste, acidity, body, balance, overall. Scores live with the batch, not the product. A 'Yirgacheffe Konga' line might have eight batches in its lifetime, each with its own scores, and customers care about the history.

In VirtueMart this lived in a custom attribute table with no foreign keys. In Shopify we modeled it cleanly: each batch is a variant, each variant carries a metafield namespace called cupping, and the historical scores are mirrored to a Sanity batch document so the origin page can render the full timeline.

# Fetch all batches for an origin with their cupping scores
query OriginBatches($originId: ID!) {
  origin(id: $originId) {
    title
    batches {
      _id
      lotCode
      roastedAt
      cuppingScore
      cuppingNotes
      shopifyVariantId
    }
  }
}

The decision to mirror data into both systems is the boring kind of pragmatism that holds up. Shopify is the source of truth for the active batch (it is what controls inventory and checkout). Sanity is the source of truth for the history (it is what renders on the origin page). A webhook from Shopify on variant creation writes the initial batch document into Sanity. Editorial fills in the tasting notes.

Exact Online and the nightly inkoopsync

The sync had three jobs: pull inventory levels from Exact into the webshop, push completed orders into Exact as sales invoices, and reconcile the supplier inkoopfacturen against incoming product batches. The Joomla version ran as a single PHP cron at 16:00 and locked the database for eleven minutes.

We rebuilt it as a Node service on a small VPS, split into three independent workers, and moved it to OAuth2 with refresh-token rotation. The Exact REST endpoints the old script used are being phased toward the bulk endpoints, which return cleaner pagination and do not time out on the inventory pull. Documentation lives at start.exactonline.nl/docs/HlpRestAPIResources.aspx.

Warning

Exact Online's rate limit is per app per division, not per user. If you parallelize the workers naively you will burn through the daily quota by 11am and the sync will fail silently until midnight CET.

The lock disappeared because Shopify owns the inventory now and the sync writes to Shopify's inventory API, not to a local MySQL table the storefront also reads from. The cart cannot lock against a sync that does not touch the cart database.

Six weeks of shadow traffic

The cutover was the part that worried the client most. Their last migration (Magento 1 to Joomla in 2014) lost two weeks of orders. We ran a shadow-traffic plan to make this one boring.

The setup: a Cloudflare Worker sat in front of both stacks. Every request to the live Joomla site was mirrored to the Hydrogen stack at shadow.client-domain.be. The user only ever saw the Joomla response. The Worker logged response codes, response times, prices shown, and stock levels from both sides.

// cloudflare/worker.js (simplified)
export default {
  async fetch(request, env, ctx) {
    const legacy = fetch(request.clone(), { cf: { resolveOverride: 'legacy.origin' } })
    const shadow = fetch(rewriteHost(request, 'shadow.origin'), { cf: { resolveOverride: 'shadow.origin' } })

    ctx.waitUntil(
      Promise.all([legacy.then(r => r.clone()), shadow]).then(([a, b]) =>
        env.DIFF_QUEUE.send({ url: request.url, legacy: snapshot(a), shadow: snapshot(b) })
      )
    )

    return legacy
  },
}

For six weeks we triaged the diff queue every morning. Week one surfaced 312 mismatches: most were currency rounding (Joomla rounded down at the line item, Shopify rounds at the cart), a handful were product variants we had collapsed too aggressively. By week four the mismatch count was under 20 a day, all of them benign. By week six the only diffs were timestamp fields and the cart-session cookie.

The shadow run also surfaced a problem we would have only found in production: 23 herkomst-pagina's had inbound links from coffee blogs that used URL fragments the Joomla SEF rewriter happened to strip. Hydrogen served them as 404s. We added the fragment-stripping behaviour to the Cloudflare redirect table.

Cutover weekend

The actual switch happened on a Saturday morning. The order timeline:

  1. Friday 18:00 CET: Joomla cart goes into read-only mode. Banner shows: 'We zijn aan het verhuizen, bestellen kan zondag weer.' Catalog browsing stays live.
  2. Friday 20:00: Final Exact sync runs. Outstanding orders reconciled.
  3. Saturday 06:00: Final product import from Joomla into Shopify. 47 products had updated prices in the read-only window (the admin does not lock). We reconciled by hand.
  4. Saturday 09:00: DNS TTL had been dropped to 60 seconds the previous Wednesday. DNS swap to Hydrogen.
  5. Saturday 10:30: First real order through Hydrogen. A roaster in Mechelen, three bags of Ethiopian, paid with Bancontact.
  6. Sunday 09:00: Cart goes live for everyone.

The thing that nearly broke us was not on the list. The new shipping-label template printed a 2mm wider barcode and the warehouse's old Zebra printer cut it off. We found out at 11am Saturday when a roaster called the line about a label scan failure. We rolled the template width back, the labels printed, the order shipped.

What we would do differently

Two things. First: we would shorten the shadow window. Six weeks was generous. After week three the diff queue had stopped surprising us. Four weeks would have been enough and would have saved the team three weeks of triage. Second: we would start the shipping-label dry run before the DNS swap, not in the warehouse on cutover morning. The barcode width was the only real incident and it was the only thing we had not actually exercised under load.

The 3,800 herkomst-pagina's are still ranking. The Exact sync runs at 04:00 instead of 16:00 and finishes in 90 seconds. The CFO's spreadsheet of order-to-invoice mismatches stopped having entries. The site is faster on a phone in Hasselt than it was on a laptop in the office.

When we built this with the importer, the thing we kept reminding ourselves was that the migration's job was to be invisible. A customer buying a 250g bag of Konga should not notice a stack change, only that the page loads faster and the basket does not time out at 16:00. That same calculus shows up in most of the legacy migration work we take on. The Hydrogen stack here was the easy half. The boring half (URL preservation, Exact rate limits, shadow diff triage) was where the bill came from.

If you are looking at a Joomla 3.x site of your own this week, the cheapest thing you can do today is grep the access logs for the top 200 inbound URLs and write them on a wall. That list is the spec for the next six weeks of work.

Key takeaway

Migrate by mirroring traffic for weeks before the DNS swap. Cutover day should be boring because everything has already been tested under real load.

FAQ

Why split Shopify and Sanity instead of using one CMS?

Shopify owns transactions, inventory, and checkout. Sanity owns long-form editorial like origin pages and batch history. Each tool stays in its lane, and references link the two.

How long should shadow traffic run before the DNS swap?

Long enough that the daily diff queue stops surprising you. For this migration that meant about three weeks. We ran six because the client wanted the cushion.

What is the biggest gotcha with the Exact Online API?

The rate limit is per app per division, not per user. Parallel workers will burn the daily quota before noon and then fail silently until midnight CET.

Can you preserve SEO when moving 3,800 URLs off Joomla?

Yes, if URL paths are kept identical on the new stack and historical drift is mapped into redirects. We resolved 47 legacy URLs through a Cloudflare redirect table.

joomlamigrationlegacy sitese-commercecase studyarchitecture

Building something?

Start a project