A few months ago I moved a batch of WordPress sites off a shared LAMP host and onto Cloudflare Pages as static exports. The pitch is obvious: no PHP process to keep patched, no MySQL to babysit, effectively free hosting, and a CDN in front of everything by default. What the pitch doesn't tell you is how many small, boring things break on the way there. This post is a rundown of what actually went wrong migrating a set of ten WordPress sites — one of them is burningtribe.tokyo, which I'll use as the concrete example — and how I fixed each issue.
The approach
The migration itself is conceptually simple: crawl the live WordPress site, save every URL as a static HTML file plus its assets, and serve that tree from Cloudflare Pages. I used a combination of wget --mirror and a custom crawler for a couple of sites where wget choked on query-string-based pagination. The static output then gets pushed with wrangler pages deploy. No build step, no framework, just files.
That simplicity is exactly why it seemed low-risk. It was not.
Problem 1: relative canonical tags pointed everything at the homepage
The first thing I noticed after deploying was that Google Search Console started reporting most inner pages as "duplicate, Google chose different canonical" — and the canonical it picked was the homepage. The cause was almost funny once I found it: the WordPress theme emitted <link rel="canonical" href="/"> as a relative path in a few cached page fragments, instead of an absolute URL like https://burningtribe.tokyo/some-post/. On the original WordPress install this didn't matter because the page itself resolved the relative reference correctly at the point of caching. Once the HTML was frozen and served statically from a different origin structure (Pages serves everything from the apex), that relative canonical collapsed to the site root for every single page that had it.
The fix was a straightforward but tedious pass: grep every exported HTML file for rel="canonical", and rewrite any relative or root-relative href into a fully qualified absolute URL matching that page's own path. I wrote a small script that walked the export directory, parsed the canonical tag, and replaced it based on the file's own location relative to the site root. Running that across all ten sites turned up the same bug in three of them — it wasn't a burningtribe.tokyo-specific quirk, it was a plugin combination that a few sites shared.
Problem 2: no sitemap.xml survived the export
WordPress sitemap plugins (I was using a common SEO plugin's built-in XML sitemap) generate their sitemap dynamically from a PHP endpoint, not as a static file anywhere in the theme or uploads directory. A mirror crawl that only follows <a href> links will never discover /sitemap.xml unless it's explicitly linked from a page, which it usually isn't — it's referenced from robots.txt and submitted directly to Search Console instead.
The result: after cutover, sitemap.xml on the new static site was either missing entirely or, worse, still referencing the old dynamic URLs that no longer existed as generated endpoints. For a couple of sites this went unnoticed for almost two weeks because the sites still ranked on already-indexed pages; the failure mode is silent. I ended up regenerating sitemaps after the fact by walking the exported file tree directly (every .html file becomes a <url> entry) rather than trying to preserve the plugin's output, since the plugin's output no longer had anything running to produce it. That also meant re-submitting fresh sitemaps in Search Console for each property, which is worth doing explicitly rather than assuming the crawler will pick up the change organically.
Problem 3: enqueued JS and CSS 404s
WordPress uses wp_enqueue_script and wp_enqueue_style to load most theme and plugin assets, and those functions frequently append a version query string, like theme.js?ver=6.4.2. A naive mirror tool that treats theme.js and theme.js?ver=6.4.2 as different resources will save one and reference the other, or save the query-string version and leave the extensionless base path unresolved depending on crawl order. I saw a batch of these appear as 404s in the Pages deployment logs for image lazy-load scripts and one analytics snippet, which quietly broke lazy-loaded images on a couple of pages until I caught it in a manual QA pass.
The fix was to strip version query strings during the export step and de-duplicate on the base filename, then verify with a link checker (linkchecker worked fine for this) run against the deployed Pages URL rather than the local export — some of these only showed up once served over HTTPS with Cloudflare's own caching layer in front, because local file serving during testing was more forgiving about missing query strings than the production edge was.
Problem 4: internal redirects had nowhere to live
The original WordPress sites had a handful of accumulated 301 redirects — old permalink structures, a couple of merged categories, one full domain change years back that was still being redirected from an old slug. Those redirects lived in a redirection plugin's database table, which obviously doesn't come along in a static export at all.
Cloudflare Pages supports a _redirects file for exactly this case, and that's where these rules ended up. The process was: export the plugin's redirect rules (most redirect plugins have a CSV or JSON export), then transform that list into the _redirects file format (/old-path /new-path 301) with a short script, and commit that file at the root of the Pages project. It's a five-minute fix once you know it's needed, but it's easy to forget entirely if you're focused on "does the site render" rather than "does the old URL still resolve," and a couple of these old redirects were still receiving meaningful traffic from long-lived backlinks.
What I'd do differently
If I were starting this migration over, I'd build the sitemap and _redirects file generation into the export pipeline from the start rather than patching them in reactively after Search Console complained. I'd also run the canonical-tag check as an automated assertion in CI before any deploy, since it's cheap to check and expensive to have silently wrong for weeks. None of these problems are exotic — they're all a version of the same root cause: WordPress plugins generate output dynamically at request time, and a static export only captures what happened to be requested during the crawl, not the underlying logic that produced it. Once you internalize that, most of the migration checklist writes itself.
The sites are stable now and load meaningfully faster than they did on the old host, so the migration was worth it. But "just export it as static files" undersells how much of WordPress's behavior is implicit and request-time-dependent rather than baked into the files themselves.
Top comments (0)