
Moving ITtelligence from WordPress.com to EmDash
Introduction: Paying for Hosting I No Longer Need
This blog has lived on WordPress.com since 2008. It does its job, but every April two charges arrive for a site that is, at this point, 106 posts and a few hundred screenshots. No comments to moderate, no shop, no plugins. It is static content with a CMS bolted on.
EmDash reached 1.0 in late September 2026. It is Cloudflare's open-source (MIT) TypeScript CMS, built on Astro, and it runs on Cloudflare Workers with D1 for the database and R2 for media. The Cloudflare free tier covers a personal blog comfortably, so the goal was simple: move everything across, keep every old URL working, and stop paying for hosting.
This article is the migration log, including the parts that did not go to plan.
1. Prerequisites
- Node.js 22.16 or newer (EmDash will not run on older 22.x releases)
- A Cloudflare account (free plan)
- Wrangler CLI 4.x
- Access to the WordPress.com admin for the export
- Git and a GitHub account
Note: If you manage Node with nvm-windows, winget upgrade OpenJS.NodeJS.LTS will report that no package is installed. Upgrade through nvm instead:
nvm install 22
nvm use 22.23.3
node -v2. Taking Stock Before Touching Anything
Before scaffolding anything I wanted to know exactly what I was moving. The WordPress REST API on WordPress.com is open, so the counts come straight from the response headers:
curl -sI "https://ittelligence.blog/wp-json/wp/v2/posts" | grep -i x-wp-total
curl -sI "https://ittelligence.blog/wp-json/wp/v2/media" | grep -i x-wp-totalThat gave 106 posts and 435 media items. The media API does not return file sizes on WordPress.com, so I sent a HEAD request to every file and summed the Content-Length headers: 50.3 MB in total, the largest a 10.1 MB screen recording. R2's free tier is 10 GB, so storage is a non-issue.
A few other things worth checking up front:
Item | Finding | Why it matters |
|---|---|---|
WordPress.com plan | Personal | No plugins, so the EmDash Exporter plugin route is out. WXR export it is. |
DNS | Already on Cloudflare | Cutover is a DNS change in a zone I already control. |
Domain registration | Still at WordPress.com | Must transfer to Cloudflare Registrar before cancelling the plan. |
Theme | Automattic Gazette | Lora, Lato and Inconsolata. Rebuilt by hand, themes do not migrate. |
Permalinks |
| EmDash's blog starter serves |
The takeaway: Ten minutes of inventory answered every question that would otherwise have surfaced halfway through.
3. Exporting from WordPress.com
On a Personal plan the export is Tools > Export > Export all content. WordPress.com emails a zip containing a single WXR (XML) file. Mine was 2.9 MB.
Do not take a successful download as proof of a complete export. Check that the file closes properly and that the counts match the live site:
tail -c 200 export.xml # should end with </rss>
grep -c "<wp:post_type><!\[CDATA\[post\]\]>" export.xml
grep -c "<wp:post_type><!\[CDATA\[attachment\]\]>" export.xml106 posts and 435 attachments, matching the API. The file also carries custom_css, wp_navigation and template parts. EmDash ignores those on import, but they are handy reference when rebuilding the theme.
Note: The WXR file contains author email addresses. Keep it out of source control.
4. Scaffolding EmDash
The scaffolder supports fully non-interactive use, which is not mentioned in the getting-started guide but is in --help:
npm create emdash@latest ittelligence-blog -- --template cloudflare:blog --pm npm --no-install --no-sandboxed-plugins --yes--no-sandboxed-plugins matters on the free plan. Sandboxed plugins need the Worker Loader binding, which is only available on Workers Paid. A blog with no plugins does not need it.
After scaffolding I made three changes:
- Renamed the Worker, D1 database and R2 bucket in
wrangler.jsoncfrom themy-emdash-sitedefaults. - Switched
tsconfig.jsonfromastro/tsconfigs/basetoastro/tsconfigs/strict.astro checkstill reported zero errors. - Added the export folder to
.gitignore.
Running locally needs no Cloudflare account at all. Wrangler emulates D1 and R2 on disk under .wrangler/state:
npm install
npm run devThe admin is at http://localhost:4321/_emdash/admin. First-run setup asks for a site title, an email and a passkey. Untick Include sample content if you are about to import.
5. Importing the WXR File
The import lives in the admin under Import WordPress. Upload the WXR, review the analysis, map the WordPress author to your EmDash user and run it. EmDash downloads every attachment from the old site, stores it through the configured storage adapter and rewrites the URLs in the content.
The headline numbers looked perfect. I did not trust them, so I compared the imported database against the WXR post by post:
Check | Result |
|---|---|
Posts | 106 of 106, all published |
Slugs | All match |
Publish dates | All match |
Categories and tags | 19 and 71, every assignment present |
Media | 381 of 435 |
Code blocks | Gutenberg code blocks converted; classic |
Tables | 1 of 9 converted |
Videos | 0 of 5 |
Images still on WordPress.com | 62 URLs across 18 posts |
The pattern is clear once you look at the source. The converter handles Gutenberg block markup well. Anything written in the classic editor, which is most of a blog that started in 2008, is processed as plain HTML, and <pre>, <table> and <video> do not survive that path. The 62 leftover image URLs are files that are referenced in posts but were never registered as attachments in the media library, so the importer had no reason to download them.
The takeaway: "Import complete" means the importer finished, not that your content arrived intact. Verify against the source.
6. Fixing the Conversion
The converter is a separate package, @emdash-cms/gutenberg-to-portable-text, so the cause was easy to find. Content with Gutenberg block comments goes through proper block transformers. Content without them falls back to a regex-based HTML path, and the regex that finds block elements starts like this:
/<(p|h[1-6]|blockquote|pre|ul|ol|figure|div|hr)[^>]*>([\s\S]*?)<\/\1>/There is no word boundary after the tag name, and p is tried before pre. So <pre> matches as a <p> tag with re treated as attributes, and the code becomes a paragraph that runs until the next </p>. Tables and video are simply not in the list.
Rather than patch a dependency, I fixed the input. The importer handles Gutenberg markup well, so a small preprocessing step rewrites every piece of classic HTML in the WXR as proper Gutenberg blocks before import:
Classic HTML | Rewritten as |
|---|---|
| `` |
| `` |
| `` |
| Its own ``, text kept as a paragraph |
| Lifted out to follow the item |
The language detail matters for syntax highlighting. Gutenberg code blocks store the language as a CSS class, but EmDash only reads it from the block attributes, so existing Gutenberg code blocks get the attribute added too.
The script checks its own work. Every rewritten post is run through EmDash's real converter, and the number of code blocks, tables and videos is compared with the source. If anything is lost, it refuses to write the file. That check caught the code-inside-a-list-item case on its first run.
npm run wxr:recover -- export/ittelligence.WordPress.2026-10-03.xml
npm run wxr:prepare -- export/ittelligence.WordPress.2026-10-03.xml export/ittelligence.prepared.xmlThe unit tests run against the real converter too, so they test what the importer will actually do, not what I assume it does.
Images That Were Already Broken
The 62 leftover WordPress URLs turned out to be a bigger story. When I checked every image referenced in every post, about 100 were already broken on the live site:
Problem | Count | Fix |
|---|---|---|
Post references | Most of the WordPress 404s | Repointed to the real file |
Thumbnail never uploaded, full size exists | A handful | Repointed to the full-size image |
Hosted on Experts Exchange (articles that started there) | 61 | Downloaded into the site itself (44 so far; Experts Exchange rate-limits) |
Hosted on the old ittelligence.com site | 10 | Not in the Wayback Machine; removed |
No matching file anywhere | 11 | Removed |
Those broken images have been broken for years and nobody told me. A migration is a good excuse to look.
The takeaway: Fix the input rather than the importer, and make the fix prove itself against the real converter.
7. Rebuilding the Gazette Look
Themes do not migrate. EmDash's blog starter is a clean, modern design, but I wanted the site to look like it always has, so the Gazette theme had to be rebuilt by hand.
Rather than eyeball it, I measured it. A short Playwright script loaded the live site and read the computed styles of every element that mattered:
Element | Value |
|---|---|
Background |
|
Text |
|
Accent (links, titles) |
|
Meta text (dates, categories) |
|
Borders |
|
Code background |
|
Body | Lora, 20px on 30px |
Headings | Lato, weight 900 |
Code | Inconsolata |
Layout | 960px site, 644px content column, 256px sidebar |
Those values became CSS custom properties, and the templates were rewritten in plain Astro and CSS: no UI framework, and no client-side JavaScript apart from the search toggle and the theme switcher.
A few things changed on purpose:
- Dark mode. Gazette never had one. Every colour is defined with
light-dark(), so the site follows the operating system, with a Light/Dark/Auto switch in the footer. The logo's navy text disappears on a dark background, so a small script produced a dark variant with the navy pixels lightened and the orange left alone. - Syntax highlighting. Gazette showed code as plain text on beige. Code blocks are now highlighted on the server with highlight.js, loading only the dozen or so languages these posts use. Shiki would have been the obvious choice in Astro, but its grammars add up quickly, and every extra megabyte in a Worker bundle costs startup time. EmDash on its own is already a 16 MB bundle. Highlighting on the server also means no extra JavaScript in the browser.
- Pagination instead of infinite scroll. Jetpack's infinite scroll became numbered pages at
/page/2/, the URL shape WordPress itself uses for paged archives.
Note: Small, tested helpers carry the logic: building permalinks, formatting dates in UTC, parsing page numbers and mapping WordPress language names to highlight.js ones. Everything else is markup and CSS.
8. Keeping Old URLs Working
Every post on WordPress lived at /YYYY/MM/DD/slug/. EmDash's starter serves posts at /posts/slug. The usual answer is a redirect table, but redirects are one more thing to maintain, and they add a round trip to every old link.
Instead, the date-based URL is the real route. The post page reads the year, month and day from the URL, looks the post up by slug and checks the date matches. If the slug is right but the date is wrong, it answers with a 301 to the correct address. /posts/slug still works too, as a 301, because EmDash's search and admin "view" links use that shape.
Building the date from the stored publish time needs one check. WordPress builds permalinks from the site's local time, while EmDash stores UTC. A post published just after midnight local time would get a different date in UTC, and a different URL. I checked all 106 posts: none crosses midnight, so the UTC date always matches the old URL.
The other WordPress URLs got the same treatment:
Old URL | Now |
|---|---|
| The post itself |
| Archive pages, with |
| Home page archive |
| RSS feed (also at |
| 301 to |
| The About page |
One post had the slug 744. That looked like an import fault, but the live URL really is /2026/03/12/744/, so it stays.
To prove it, a script reads every published URL out of the WXR export (posts, the page, and every category and tag in use) and requests each one from the new site:
npx tsx scripts/check-urls.ts export/ittelligence.WordPress.2026-10-03.xml168 URLs checked, 0 not 200The takeaway: If the old URL can be the new URL, you do not need a redirect table at all.
9. Deploying to Cloudflare
The deploy itself is short. Create the resources, point wrangler.jsonc at them, store the encryption key as a secret and deploy:
npx wrangler d1 create ittelligence-blog --binding DB --update-config
npx wrangler r2 bucket create ittelligence-blog-media --location oc
npx wrangler kv namespace create ittelligence-blog-session --binding SESSION --update-config
npx wrangler secret put EMDASH_ENCRYPTION_KEY
npm run deployNote: R2 has to be enabled once in the Cloudflare dashboard before Wrangler can create a bucket, and enabling it asks for a payment method even on the free tier.
The site went to a temporary hostname first, new.ittelligence.blog, with WordPress still serving the real domain. Two things on the way are worth knowing before you do the same.
Passkeys Are Bound to a Hostname
EmDash signs you in with passkeys, and a passkey only works on the domain it was created for. Set up the admin on a temporary hostname and that passkey is useless after cutover. EmDash handles this with EMDASH_SITE_URL plus EMDASH_ALLOWED_ORIGINS, but there is a catch: EmDash also builds its admin redirects from the site URL. Point it at the final domain too early and every admin login bounces to the old site.
The order that works is to keep the site URL on the temporary hostname until cutover, then switch it while still signed in and add a passkey for the final domain from that open session. Canonical links and the RSS feed come from Astro's own site setting, so they point at the real domain the whole time, and the temporary hostname is marked noindex.
The Free Plan Is Not Enough
This was the surprise of the whole migration. The plan was to run on the Workers free tier, and the bundle fits (Cloudflare now allows 64 MiB on every plan). The CPU limit is a different story. Live logs from wrangler tail told the story:
Request | CPU time |
|---|---|
Free plan limit | 10 ms |
Home page | 60 to 90 ms |
RSS feed | Over 10 ms |
One import batch | 200 to 800 ms |
Cloudflare tolerates a short burst over the limit, so the first few pages loaded and 38 posts imported. Then every request, pages included, came back as a 503 with Worker exceeded CPU time limit. EmDash does a lot of work per request, and 10 ms is not enough for it.
The fix was Workers Paid at US$5 a month. One more trap: the Worker kept running under free-plan limits until it was redeployed, so the import kept failing after the upgrade. Setting the limit explicitly in wrangler.jsonc and deploying again sorted it:
"limits": { "cpu_ms": 300000 }Verifying Production
The import needed a few runs. The failed attempts left one post without its categories and tags, and the importer does not rewrite video URLs, so five videos still pointed at WordPress.com. Both were fixed with a few lines of SQL through wrangler d1 execute. Then the same checks as locally, against the live hostname:
npx tsx scripts/check-urls.ts export/ittelligence.WordPress.2026-10-03.xml https://new.ittelligence.blog168 URLs checked, 0 not 200The takeaway: Measure CPU time before you commit to a plan. wrangler tail --format json shows it for every request.
11. Cost
Per year | |
|---|---|
WordPress.com (two charges of US$76.66) | US$153.32 |
Cloudflare Workers Paid | US$60.00 |
R2, D1 and KV at this size | US$0 |
The saving is smaller than the "free hosting" I set out for, but it is still more than half, and the site is now a codebase I own rather than a theme I rent. Domain registration moves to Cloudflare Registrar either way.
Related Articles
- How to Set Up Claude Code Properly
- Model Context Protocol – Connecting AI to the World