On 12 July 2026 I moved a Romanian appliance-review site I run from WordPress to Hugo, served by a Cloudflare Worker. The whole job, from deciding what to keep to switching the domain over, took one day. Claude Code did most of the hands-on work. I supervised the project and double-checked that everything was right before and after the switch.
The question everyone asks about a migration is whether it costs traffic. Here is my answer from Google Search Console. The 58 pages I migrated got 356 clicks in the 91 days before the move, and exactly 356 in the most recent 91 days. Their impressions went from 8,634 to 19,911. The site as a whole went from 357 clicks to 1,041 in the same windows, but that growth came from new articles published after the move, not from the migration itself.
This post covers how I did it and the problems I hit, in the order I hit them.
Why I left WordPress
The site is now written by an automated pipeline that I describe in how I run Hermes Agent on my NAS. The pipeline produces Markdown files and publishes by pushing to Git. With WordPress, every article had to go through the REST API, and the database, plugins and hosting were things to maintain that added nothing for a site nobody edits by hand. A static site on Cloudflare has no database to update, no PHP and no plugin updates, and a push to Git is the whole publishing step. It also saves money: the WordPress hosting cost me around $100 a year, and the site now costs me nothing to host. It runs on Cloudflare’s free plan, which doesn’t charge for serving static files from a Worker, and the Worker code only runs for old URLs.
I chose Hugo because I already ran another site on the same setup, so the build, deploy and pipeline stages could be shared. That matters more than the choice of generator. If you edit in the browser and rely on plugins for forms or shops, the trade-off is different.
Step 1: decide what to keep
The old site had about 860 URLs, most of them thin product reviews from 2015 to 2017 that nobody visited. Moving all of them would have carried the dead weight over. Moving only the obvious winners would have thrown away pages that still earned a few clicks a month.
I pulled 16 months of page-level data from Search Console instead of the usual 90 days, so that seasonal pages (air conditioners in summer, for example) wouldn’t be judged on their off season. The rule was: keep a page if it had at least 5 clicks or 540 impressions over those 16 months. That is the same intensity as “1 click or 100 impressions in 90 days”, scaled to the longer window. A plain “at least one click in 16 months” rule would have rescued about 40 dead 2015 reviews.
| Decision | URLs |
|---|---|
| Kept and migrated | 58 |
| 301 redirect to the closest surviving category page | 711 |
| 410 Gone (no sensible target) | 92 |
Three things nearly slipped through:
- WordPress pages, not just posts. My first inventory script only looked at posts, so seven published pages fell into neither list. One of them, a buying guide with 2,773 impressions, was among the best pages on the site.
- URLs that were already 404 on WordPress. Old category archives from before an earlier restructure still had impressions in Search Console. One had 1,476. I added 68 of them to the redirect map, because Google was still showing them.
- Pages linked from the content that never existed. A few articles linked to slugs that had never been published. They are not in any redirect map because there was never a page there, so they stay 404 until something is published at that slug.
Step 2: export the content
I exported every kept page through the WordPress REST API: the rendered HTML converted to Markdown, plus the date, last-modified date, categories, and the SEO title and meta description from the SEO plugin, which needs an authenticated request because that data is post meta. Images were downloaded with their original filenames.
One lesson: the live site wins. Some articles had local Markdown sources, and I expected to use those. But articles had been edited after publishing, and one local file turned out to be a completely different draft that was never published. I compared each source with the live page and took the live version every time.
Every old image URL (/wp-content/uploads/2017/03/...) went into a second map, 255 rows from the old path to the new one, so image links from other sites and Google Images keep working.
Step 3: the Hugo site and the redirect layer
The site runs as a Cloudflare Worker with static assets. Cloudflare builds Hugo on every push to the repository and serves the built pages directly. The Worker’s own code only runs when no built file matches the request, which makes it the natural place for the old URLs. The two maps from steps 1 and 2 are turned into one JSON file at build time, and the Worker looks the path up:
import redirects from "./redirects.json"; // { pages: {...}, uploads: {...} }
export default {
async fetch(request, env) {
const url = new URL(request.url);
// URLs arrive percent-encoded; old filenames with accents won't match otherwise
let path;
try { path = decodeURIComponent(url.pathname); } catch { path = url.pathname; }
const slashed = path.endsWith("/") ? path : path + "/";
if (Object.hasOwn(redirects.pages, slashed)) {
const target = redirects.pages[slashed]; // null means "gone"
return target
? Response.redirect(url.origin + target, 301)
: new Response("Gone", { status: 410 });
}
if (path.startsWith("/wp-content/uploads/")) {
const target = redirects.uploads[path];
return target
? Response.redirect(url.origin + target, 301)
: new Response("Gone", { status: 410 });
}
return env.ASSETS.fetch(request); // normal 404 page
},
};Code language: JavaScript (javascript)
This is a simplified version. My real one also catches URL patterns that are not worth listing one by one, such as old /page/N/ archives, and serves a proper “this page is gone” page with links to current reviews instead of a bare 410.
Two details matter here:
- Decode the path before looking it up. Browsers send
Tuttocaff%C3%A8.jpg, while my map hadTuttocaffè.jpg. WithoutdecodeURIComponent, every image with an accent in its filename returned 410. - The Worker can’t redirect www to the bare domain. Static files are served before the Worker runs, so
www.example.com/some-page/would get the page, not a redirect. I used a Cloudflare redirect rule on the zone for that, which WordPress used to handle itself.
Step 4: a parity check script
Before switching, I had a script check the preview site against the plan. It is the most useful part of the whole migration, because the same script ran again against the real domain right after the switch. It checks that:
- every kept URL returns 200 at its exact old slug, with the same SEO title and meta description as on WordPress;
- every image on those pages loads;
- the sitemap contains exactly the kept URLs and none of the removed ones;
- a sample of removed URLs returns the planned 301 or 410;
- old
/wp-content/uploads/image URLs redirect to the new files; - the structured data on review pages parses.
It found two real bugs. Two meta descriptions were HTML-encoded twice, so Google would have seen ' instead of an apostrophe. And the accented filenames above returned 410. Both would have been easy to miss by clicking around.
Step 5: switch over without touching DNS
The domain was already on Cloudflare, proxied, so the switch was a Workers route for the domain plus the www redirect rule. No DNS records changed. WordPress kept running behind the proxy, and rolling back meant deleting the route, which a one-line script could do. I never needed it.
Right after the switch I:
- ran the parity check against the live domain (all passed: 59 pages, 103 images);
- submitted the new sitemap in Search Console and removed the old WordPress sitemaps. Look for
http://andwwwversions too: two of mine stayed registered for months, still listing 848 dead URLs; - turned off the
workers.devpreview address, so Google can’t find a second copy of the site, and setworkers_dev = falseinwrangler.tomlso a later deploy doesn’t turn it back on; - set up a daily check: a few kept URLs return 200, the www and old-URL redirects work, and 7-day impressions stay above 70% of the pre-move level.
A week later, impressions were at 99% of the pre-move level.
What happened next
The migrated pages held, as the numbers at the top show. The surprise was new pages. Articles published after the move took weeks to get indexed. After three weeks, 20 new articles had produced one click between them, and six of them were still unknown to Google even though they were in the sitemap.
The worst case was the most important new article. After 48 days Search Console showed it as “Crawled – currently not indexed”: Google had looked at it and decided not to index it. I used “Request indexing” on it and two other stuck pages, and all three were indexed within hours. My read is that a migrated domain needs time to earn crawl trust back, and a manual request helps a stuck page. It doesn’t mean the content was the problem.
Around the seventh week, new articles started to bring traffic. In the week of 24 August they produced 38 of the site’s 63 clicks. Clicks per month for the whole site:
| Month | Clicks | Note |
|---|---|---|
| June 2026 | 123 | last full month on WordPress |
| July 2026 | 183 | moved on 12 July |
| August 2026 | 212 | |
| September 2026 | 343 | |
| October 2026 (to the 24th) | 436 |
Again, this growth is new content, not the migration. What the migration did was not lose anything, and make publishing that content cheap.
What I fixed later
- Nothing was cached by the browser. By default every file, including fonts and images whose names already contain a content hash, came with
max-age=0, so every page view revalidated every file. A_headersfile in Hugo’sstatic/folder fixed it. Two quirks: Cloudflare allows only one*per rule, and if two rules match the same file, their Cache-Control values are joined together. So write rules that don’t overlap:
/fonts/*
Cache-Control: public, max-age=31536000, immutable
# Hugo image derivatives: <name>_hu_<hash>.<ext>
/*_hu_:hash
Cache-Control: public, max-age=31536000, immutable
/css/main.:hash.css
Cache-Control: public, max-age=31536000, immutableCode language: plaintext (plaintext)
- Thumbnails were full-size images. The “related reviews” box showed 72-pixel thumbnails using the original images, one of them 2.26 MB. Resizing them in the template with Hugo’s image processing (
.Fill "144x144 webp q75") took those images from 12 MB in total across the site to under 300 KB. - A missing favicon.ico. The new theme only had an SVG icon, but browsers and bots still ask for
/favicon.ico: 97 requests in one week, all 404. A realfavicon.icoinstatic/fixed it.
Was it worth it?
Yes. In October I decided WordPress was no longer needed for this site: the migrated pages kept their traffic, the new articles are indexed and bring most of the new clicks, and the site has no server, database or plugins to maintain, and the $100-a-year hosting bill is gone: Cloudflare’s free plan covers it. The keep-or-redirect decision and the parity check are what made it safe. If I did it again, I would set up browser caching on day one and use “Request indexing” on any important new page that is still not indexed after two weeks.
