Moving My WordPress Site to EmDash on Cloudflare Workers

On this page
bcodecraft.com ran on WordPress for two years. It had one blog post, eighteen pages, an empty WooCommerce shop and a mobile Lighthouse score that dropped to 35 on the Mochi Focus page. This week I moved it to EmDash, the CMS Cloudflare released this year, running on Cloudflare Workers with D1 and R2.
I did it in one long session with Claude Code. It wrote most of the scripts and the theme, and I made the calls: what to keep, what to drop, and what the site should look like. This post is what we did, what broke, and the numbers before and after.
What I couldn't break
A portfolio site sounds easy to move until you look at who links to it. Google Play and the Chrome Web Store link to the privacy policies of my apps, and those pages lived on WordPress:
- eight privacy, terms and data-deletion pages for Mochi Focus, GuessGenius, SubSuperScript, Unicode Text Style and a small game extension
- a Mochi Focus feedback page with a working form, linked from inside the extension
- the old post, its category and six tags
If a store's crawler hits a 404 on a privacy policy, the app can get flagged. So before writing any theme code, we wrote the tests:
- check-urls.py pulls every URL from the old Yoast sitemaps and checks that each one answers 200 on the new site, plus a list of old URLs that must redirect (the empty shop, cart and author pages).
- check-legal.py compares the visible text of each legal page with the WordPress original, word for word. The pages may look different, but they must say exactly the same thing. One policy even has the date "2024-13-07" in it, and that typo is preserved on purpose.
- check-seo.py compares title, description, canonical and JSON-LD for every page against a snapshot of what Yoast rendered.
These ran red first, against an empty site, and every later step was "make them green".
Moving the content
EmDash imports a WordPress export (WXR) file or connects to an exporter plugin. I didn't want to install anything on the old site, so we built the WXR from the public REST API instead. It's read-only, needs no password, and covers published content, which was all I had.
The import itself was quick. These are the snags we hit:
- Every page failed with excerpt: unknown field on collection 'pages'. The public API returns an auto-generated excerpt for pages too. The fix was adding an excerpt field to the pages collection.
- Titles came through as Mochi Focus – Feedback. The REST API returns rendered titles with HTML entities, so they need unescaping before they go into the WXR.
- The old SVG logo was rejected with "File type not allowed". EmDash doesn't accept SVG uploads. That logo was retired anyway.
- Two pages were nothing but WPForms markup: inline CSS, a "please enable JavaScript" line and a script. The contact and feedback forms had to be rebuilt as real forms with a small Worker endpoint.
The local site was then moved to Cloudflare with EmDash's site transfer: export a package locally, upload it, check the plan, confirm. It verifies a digest at the end, which is reassuring when you're moving a database you can't easily inspect. Media files get new storage keys on the way, so a small script remaps the old /wp-content/uploads/ paths to the new keys. Old image links on other sites still work through a redirect.
The image problem Lighthouse found
The first Lighthouse run on staging scored the Mochi Focus page 77, with a layout shift of 0.21. The imported images had no width or height, so every screenshot pushed the text down when it loaded, and they were served as full-size PNGs.
The fix was a script that looks up each image in the media library and writes its real dimensions into the content blocks. With sizes known, EmDash renders responsive WebP through Cloudflare Images, and the layout shift went to zero.
After launch, a fresh run on production gave the same page 83 with a 4.4-second LCP. The LCP element was the first screenshot, and EmDash marks every image in the content loading="lazy", so the browser waited before fetching the one image it needed first. The fix is a few lines in the Worker's middleware: Cloudflare's HTMLRewriter switches the first content image to loading="eager" with fetchpriority="high" as the page streams out, and leaves the rest lazy.
The bug that surprised me: the cache doesn't know the host
The site lives on www.bcodecraft.com, and bcodecraft.com should redirect to it. We did that in the Worker's middleware and tested it. After switching production over, the apex homepage answered 200 instead of redirecting:
$ curl -sI https://bcodecraft.com/blog/
HTTP/2 200
cf-cache-status: HITUncached paths redirected fine, and cached ones didn't. The site uses the Workers edge cache in front of the Worker, and Cloudflare's docs say that cache is keyed by path and query string, not by host. So once www.bcodecraft.com/blog/ was cached, a request for bcodecraft.com/blog/ got the same cached page. The Worker never ran, and neither did the redirect inside it.
The fix was moving the apex to its own tiny Worker with no cache at all:
const SITE = "https://www.bcodecraft.com";
// WordPress URLs end in "/": add it to page paths in the same hop.
export function target(url) {
const u = new URL(url);
let path = u.pathname;
if (!path.endsWith("/") && !path.startsWith("/_") && !/\.[a-z0-9]+$/i.test(path)) path += "/";
return SITE + path + u.search;
}
export default {
fetch(request) {
return new Response(null, { status: 301, headers: { Location: target(request.url) } });
},
};A link to bcodecraft.com/privacy-policy-for-guessgenius now reaches the right page in one hop. I checked another site of mine on the same setup and found the same bug, with www serving cached pages from the apex, and fixed it the same way. One more thing follows from this: keep "Always Use HTTPS" switched on in the zone. Otherwise a plain http:// request can be answered from the cache before your Worker gets the chance to redirect it.
Keeping bots out of the contact form
The rebuilt form started with a honeypot field, a check that the form wasn't sent within three seconds of the page rendering, and an Origin check. A code review caught that the timer was broken. The contact page was edge-cached too, so every visitor got the same render timestamp, and an instant bot submission looked like it had waited for hours.
Form pages are now never cached, and the forms use Cloudflare Turnstile. The Worker calls siteverify before anything is sent and accepts the token only if the action matches the form and the hostname is mine. If siteverify fails or times out, the message is refused. We tested it on production with a real token: the first submission went through, and replaying the same token was refused.
The part I actually enjoyed: the homepage
The first theme was clean and fast, with a hero, project cards and a minigame. When I saw it on staging, it looked like every AI-generated landing page. So the homepage became a small 3D island in the style of my Mochi Focus extension: flat-shaded pastel low-poly, an isometric camera and a sky that follows the visitor's clock. Every object on the island is a project:
- a workbench with a laptop for the blog
- Mochi itself for Mochi Focus
- basalt columns for Basaltframe
- a die for GuessGenius
- a plug for my WordPress plugins
Ladybugs wander around, and squashing one grows a flower.
The scene is three.js (137 KB gzipped) and loads after the page has painted, so the greeting and the project list are plain HTML that search engines and screen readers get right away. With "reduce motion" switched on, you get one still frame. Without WebGL, you get a picture.
Before and after
Lighthouse, mobile, same machine and settings. The new numbers are the median of three runs on production. Scores move a few points between runs, and the very first run after a deploy is lower while the caches fill.
Page | WordPress | EmDash on Workers |
|---|---|---|
Homepage | 70 | 93 (with the 3D island) |
Blog post | 94 | 99 |
Mochi Focus page | 35 | 92 |
PageSpeed Insights runs the same test from Google's servers and simulates a slower phone, and it scored lower at first: 83 for the homepage and 85 for the Mochi Focus page on mobile. After three more fixes it gives the homepage 100 and the Mochi Focus page 92, with 100 for accessibility, best practices and SEO on both. EmDash's image endpoint was sending max-age=0, so every view re-encoded every image, and the database only lived in Asia, so a visitor in the US waited on a round trip across the Pacific for each query. Image responses are now cached at the edge, and D1 read replication serves reads from the nearest copy. The third fix came from PageSpeed's new agent audit, which timed out fetching /llms.txt: it and the sitemaps were rebuilt from the database on every request, and now they're cached until the next publish.
Every old URL still answers, the store listings' privacy links open the same text, and the site now has a meta description on every page. Yoast had left most of them empty.
FAQ
Can I migrate to EmDash without installing a plugin on WordPress?
Yes. You can build a WXR file from the public REST API, which needs no login. It only sees published content and returns rendered HTML, so expect to clean up entities and plugin markup.
Will my WordPress URLs change?
Not if you set the collection URL pattern to match your permalinks (for me, /{slug}/ with a trailing slash) and test every old URL before switching DNS or routes.
Is EmDash ready for a real site?
For a small site like mine, yes. Expect to write some glue yourself. Plugins don't come across, only content, and forms, redirects and SEO details are your job.
If you want to see what all of this was for, the island is on the homepage. If you write with WordPress, my earlier post on a plugin that writes internal links is still there, at the same URL.