Free My Beer had been running on Shopify since late 2025. It worked, and I had no complaints about the checkout. But every month I paid the subscription, the payment fees and a handful of apps (Instafeed and friends) for a niche shop selling gluten-free beer. Meanwhile, I have a VPS already running WordPress sites (Oh le Kayou, AFE) that is mostly idle.
A Shopify to WooCommerce migration is the kind of project you postpone for months because it takes "at least two weeks". In the end, with Claude Code pair-programming in the terminal, the shop switched over in 12 hours of actual work, spread across two days alongside other things. Same design, SEO preserved, order numbers continuing where they left off.

For context on the shop itself, see this article. This one is the technical write-up.
What had to move, and the ground rules
The Shopify inventory: 17 beers, 3 collections, 3 pages, 15 blog posts, 68 customers, 46 orders numbered #FMB1001 to #FMB1046. A custom Liquid theme, a homemade checkout app that makes the phone number mandatory, and automated invoicing from Shopify to n8n, which renders a PDF, stores it in Google Drive and creates a Notion entry.
I set non-negotiable constraints before touching a single line:
- reproduce the existing design exactly, all styling in Tailwind CSS v4;
- French only;
- zero SEO loss: 301 redirects for every old URL, titles and descriptions carried over;
- order number continuity (the next WooCommerce order must follow #FMB1046, not restart at 1).
That last one is the classic detail you forget and that makes your accountant panic six months later.
The method: spec, plan, execution, review
I used Claude Code with Claude Fable 5.1 and the superpowers process. The idea: no coding right away. Brainstorming, then a written spec, then a plan split into tasks, then execution, then a review by an independent subagent that does not share the context of the one that wrote the code.
It sounds heavy for a side project. In practice, it is what made the 12 hours possible. The first commit on the duplicate-to-wc branch (09/28 at 9:14 am) contains the theme spec and plan, no code. Then Claude executes the plan task by task, a subagent reviews, and I decide whenever there is a call to make. Very little backtracking, because the structural questions were asked upfront.
The split of roles was clear. Claude wrote the code, the scripts, the tests, and ran the checks. I picked the building blocks (Stripe, Boxtal), validated the shipping rates, handled DNS, secrets and every OAuth connection. Nothing touching an account or money went through without me.
Day 1, morning: local stack and theme
Everything runs in Docker: MariaDB, WordPress on php8.3-apache, a dedicated wp-cli container and a Node container to build Tailwind.
The core piece is an idempotent setup.sh in WP-CLI. It installs WooCommerce, creates the French pages, configures the France shipping zone, VAT and plugins. You can run it ten times, it breaks nothing and duplicates nothing. That matters a lot when switching between local and production.
The fmb-woo theme reproduces the Shopify design. I kept the Shopify template logic: editorial content (homepage copy, blocks, etc.) lives in JSON files, not hardcoded in PHP templates. All business logic lives in an fmb-core mu-plugin: product metafields, settings, redirects, shipping, orders, emails, invoices.
To nail the styling, we went back and forth with screenshots. I showed the Shopify and WooCommerce renders side by side, pointed out the gaps (white background instead of cream, buttons, tables, spacing), Claude fixed it in Tailwind and redeployed. Not glamorous, but that is how you get "identical" instead of "close enough".
Day 1, midday: migrating data without duplicates
The migration has two steps. A Node script queries the Shopify Admin GraphQL API (version 2025-07, client credentials auth) and dumps everything to JSON. Then a PHP import reads those files and creates products, collections, pages, blog posts, menus, customers and orders in WooCommerce.
Every imported object keeps a _fmb_shopify_id key. If the object already exists, it gets updated instead of duplicated. The import can be replayed at will, and you can target a single object type:
# Reimport products only, leave everything else alone
docker compose run --rm wpcli /scripts/import.sh --only=products
# Dry run against fixtures, writes nothing
docker compose run --rm wpcli /scripts/import.sh --only=products,orders --dry-run --data=/migration/fixtures/data
The API does not expose the full order history in a usable way, so I completed it with the Shopify admin CSV export: 46 orders through the API, 186 history lines through the CSV.
Day 1, afternoon: production, payments, shipping, cutover
Deployment on the VPS behind Traefik with Let's Encrypt, like my other sites. Three scripts: deploy.sh, migrate-to-prod.sh, and cron backups.
Payments go through Stripe: classic checkout plus express payment buttons. Shipping has three options: Colissimo home delivery by weight bracket, Mondial Relay pickup points through Boxtal Connect, and local pickup. The rates live in wordpress/wp-content/mu-plugins/fmb-core/shipping.json, which I can edit without touching code (excerpt):
{
"colissimo": {
"title": "Colissimo domicile (2 à 4 jours ouvrables)",
"default_item_kg": 0.45,
"tranches": [
{ "max_kg": 2, "prix": 10.9 },
{ "max_kg": 5, "prix": 15.9 },
{ "max_kg": 8, "prix": 19.9 },
{ "max_kg": 30, "prix": 24.9 }
]
}
}
Transactional emails through OVH SMTP. Then 301 redirects from every Shopify URL to its WooCommerce equivalent: /products/…, /collections/…, /pages/…, /blogs/…, /cart, /account, /policies/…. Every URL Google has indexed must land somewhere sensible, otherwise you lose the little SEO a niche shop took months to earn.
DNS cutover on 09/28, late in the day.
Day 2: SEO, invoices and performance
For SEO, Rank Math with metadata copied from Shopify, the sitemap, and noindex on cart and account pages. I also added an llms.txt for AI agents reading the site.
VAT: 20% on alcoholic beers, 5.5% on alcohol-free ones.
The most satisfying part was invoicing. The n8n workflow already existed for Shopify. I pasted its JSON into the chat. Claude read it, adapted it to the WooCommerce payload, tested it locally against a fake n8n running the workflow's real code, then created it directly in my n8n instance through the n8n MCP server. The final flow: WooCommerce sends an HMAC-signed webhook, n8n renders the PDF with Gotenberg, stores it in Drive, creates the Notion entry, and sends the invoice URL back to WordPress. The customer gets an "Invoice" button in their account and receives it by email.
Last task, mobile performance. Lighthouse went from 63 to 98, and LCP from 8.2 s to 2.3 s, without any caching plugin. Most of the gain came from a single file: the hero image was a 774 KB PNG. Converted to WebP (63 KB), with srcset and preload, that covered most of it. The rest: self-hosted Google Fonts, one-year Cache-Control plus gzip on assets, and removing useless scripts. The Instagram feed runs on a free plugin.
Last polishing commit on 09/29 at 11:28 am.
Three problems and how they got solved
Shipping costs inflated by 20%. WooCommerce treats a shipping method's cost as tax-exclusive and adds VAT on top. My rates were tax-inclusive (the prices I want customers to see). Result: €10.90 became €13.08 at checkout. I caught it testing the cart in production, and it was fixed right away by converting the rates to tax-exclusive when declaring them:
// wordpress/wp-content/mu-plugins/fmb-core/shipping.php
// Les grilles sont en TTC ; WooCommerce attend un coût HT et ajoute la TVA livraison.
function fmb_shipping_cost_ht( float $ttc ): float {
if ( $ttc <= 0 || ! wc_tax_enabled() ) {
return $ttc;
}
$rate = 0.0;
foreach ( WC_Tax::get_shipping_tax_rates() as $r ) {
$rate += (float) $r['rate'];
}
return $rate > 0 ? round( $ttc / ( 1 + $rate / 100 ), 4 ) : $ttc;
}
Silent Rank Math. Plugin installed, metadata filled in, and nothing in the public <head>. Rank Math outputs nothing until the account registration step is "skipped". You need to set the rank_math_registration_skip option to true, which setup.sh now does.
The blog post FAQs, lost. This one is not solved. The Shopify app tokens kept expiring, and the app was cut off before the export finished. The blog post FAQs, stored in a metafield, were never retrieved. The myshopify domain now redirects to the new site, and there is no Wayback Machine archive. They are gone.
A few smaller hiccups. An unfortunate chown -R on folders mounted from git in production broke git pull (fixed). The Stripe plugin would not install from the WordPress admin because of permissions, so everything went through WP-CLI, which is more reproducible anyway.
And one thing I see as a plus: some commands, like injecting a secret into n8n, were refused by Claude Code's security classifier. So I ran them myself. That is exactly what I want from a tool with shell access to my production server.
The numbers
| Metric | Value |
|---|---|
| Actual time | ~12 h over two days |
| Commits | 69 |
| Files changed | 127 |
| Lines added | ~11,700 |
| Data migrated | 17 products, 3 collections, 15 posts, 68 customers, 46 API orders + 186 CSV history lines |
| Lighthouse mobile | 63 → 98 |
| LCP | 8.2 s → 2.3 s |
On cost, the VPS already existed and is shared with other sites. The plugins are all free: WooCommerce, Stripe, Boxtal Connect, Rank Math, Instagram Feed, Contact Form 7.
In practice, I save around €33 a month, plus Shopify's transaction fees. More importantly, I am out of an ecosystem where many interfaces are proprietary and locked down: on Shopify, making the phone number mandatory at checkout required a homemade app. On WooCommerce, it is my code, on my server, and I change it however I want.
Verification was systematic at every step: a smoke.sh script with 18 checks, headless Chrome screenshots to compare renders, and a local Lighthouse run before pushing.
What I would do differently
I would export everything, metafields included, before touching anything, and keep those exports out of the migration's reach. The lost FAQs only happened because the export and the app shutdown overlapped. A full dump on day 0, stored separately, and the problem would not exist.
I would also test a real cart in production much earlier, with real taxes enabled. The tax-inclusive bug did not show up with test data.
What is left
Submit the new sitemap in Search Console and watch crawl errors on the old URLs. Follow the first real order end to end: payment, label, invoice, email. And rewrite the blog post FAQs by hand.
What struck me most is where my time went. Almost none of it in code. It went into decisions: which payment provider, which rates, which URLs redirect where, what to accept as a loss. Claude produced the 11,700 lines, but it made none of those decisions. I wonder if that is what a developer's job looks like on this kind of project now: write less, arbitrate and verify more.