Terminal showing the migration: 106 posts prepared, 54 tests passing, 168 old URLs checked
Development

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 -v

2. 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-total

That 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

/YYYY/MM/DD/slug/ on all 106 posts

EmDash's blog starter serves /posts/slug. Needs a route.

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.xml

106 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:

  1. Renamed the Worker, D1 database and R2 bucket in wrangler.jsonc from the my-emdash-site defaults.
  2. Switched tsconfig.json from astro/tsconfigs/base to astro/tsconfigs/strict. astro check still reported zero errors.
  3. 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 dev

The 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 <pre><code> became plain paragraphs

Tables

1 of 9 converted

Videos

0 of 5 <video> elements converted

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

<pre><code class="language-powershell">

``

<table>

``

<video src>

``

<img> inside a paragraph

Its own ``, text kept as a paragraph

<pre> inside a list item

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.xml

The 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 x.jpg, library holds x.png

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

#FEFCF8

Text

#222222

Accent (links, titles)

#B8541E

Meta text (dates, categories)

#9C6E20

Borders

#EDD9B5

Code background

#F7EDD8

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:

  1. 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.
  2. 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.
  3. 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

/YYYY/MM/DD/slug/

The post itself

/category/name/ and /tag/name/

Archive pages, with /page/N/

/page/N/

Home page archive

/feed/

RSS feed (also at /rss.xml)

/?s=term

301 to /search?q=term

/about/

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.xml
168 URLs checked, 0 not 200

The 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 deploy

Note: 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.blog
168 URLs checked, 0 not 200

The 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