Migrating a WordPress Export to Eleventy

WordPress-to-static migrations usually start with a tool that promises a one-click conversion, and usually end with someone writing a script anyway, because every WordPress site has its own plugins, shortcodes, permalink history and media habits. Eleventy is a good destination for these sites: it reads Markdown and HTML equally happily, so content that does not convert cleanly can stay as HTML, and its permalink control is flexible enough to reproduce almost any WordPress URL scheme.

This guide converts a 12-year-old WordPress blog — 1,430 posts, 38 pages, 9,600 comments, 4,200 media files, Gutenberg and classic editor content mixed — into an Eleventy 3 site, preserves every URL, and checks the result against the live site. The broader decision of whether and where to migrate is covered in Migrating WordPress to a Static Site Generator; this guide is the Eleventy-specific how-to, part of Migrating Between Static Site Generators.

Prerequisites

  • A WXR export (Tools → Export → All content) and a copy of wp-content/uploads.
  • The WordPress permalink setting (Settings → Permalinks) — here /%year%/%monthnum%/%postname%/.
  • A crawl of the live site's URLs for later comparison: wget --spider -r or the sitemap.
  • Node.js 20 and an empty Eleventy 3 project.

Step 1: Parse the Export

WXR is RSS with WordPress extensions. fast-xml-parser reads it in a few seconds:

import { XMLParser } from 'fast-xml-parser';
const xml = await fs.readFile('export.xml', 'utf8');
const { rss } = new XMLParser({ ignoreAttributes: false, cdataPropName: '__cdata' }).parse(xml);
const items = rss.channel.item;
const posts = items.filter((i) => i['wp:post_type'] === 'post' && i['wp:status'] === 'publish');
const attachments = new Map(items.filter((i) => i['wp:post_type'] === 'attachment')
  .map((a) => [a['wp:post_id'], a['wp:attachment_url']]));

Counting by type and status first catches surprises: this site had 212 drafts, 61 private posts and 3 custom post types from old plugins, all of which needed a decision before conversion.

Items in the WordPress export by type Horizontal bars. Attachments 4,200. Published posts 1,430. Drafts 212. Private posts 61. Pages 38. Custom post types from plugins 27. Navigation menu items 44. What was in the export Attachments 4200 Published posts 1430 Drafts 212 Private posts 61 Menu items 44 Pages 38 Custom post types 27
Counted from the WXR file; only published posts, pages and attachments were converted.

Step 2: Convert HTML to Markdown

Each post's content:encoded is HTML with Gutenberg block comments (or classic editor HTML). Turndown with a few custom rules handles almost all of it:

import TurndownService from 'turndown';
import { gfm } from 'turndown-plugin-gfm';
const td = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
td.use(gfm);
td.addRule('wpCode', {
  filter: (n) => n.nodeName === 'PRE' && n.classList.contains('wp-block-code'),
  replacement: (_, n) => `\n\`\`\`${n.querySelector('code')?.className.replace('language-', '') ?? ''}\n${n.textContent}\n\`\`\`\n`,
});
td.keep(['figure', 'iframe', 'video']);   // leave complex markup as HTML
const md = td.turndown(html.replace(/<!-- \/?wp:[^>]*-->/g, ''));

WordPress shortcodes in classic posts ([caption], [gallery], [embed], two plugin shortcodes) were converted before Turndown with regexes: [caption] became <figure>/<figcaption>, [embed] and bare YouTube URLs became lite-youtube embeds, and [gallery ids="..."] became an Eleventy shortcode {% gallery "12,15,18" %} that renders the images from the attachment map. 37 posts with unusual markup were left with their HTML intact and reviewed by eye.

Step 3: Write Files With Front Matter

const slug = post['wp:post_name'];
const date = new Date(post['wp:post_date_gmt'] + 'Z');
const fm = {
  title: post.title.__cdata ?? post.title,
  date: date.toISOString(),
  wpId: post['wp:post_id'],
  categories: cats(post, 'category'),
  tags: cats(post, 'post_tag'),
  author: post['dc:creator'].__cdata,
  permalink: `/${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(2, '0')}/${slug}/`,
  description: excerpt(post),
};
await fs.writeFile(`src/posts/${slug}.md`, `---\n${yaml.dump(fm)}---\n\n${md}\n`);

Keeping wpId in front matter is what makes the redirects in Step 6 possible. Using post_date_gmt avoids posts near midnight moving to the wrong month — the same timezone trap that shifts dates in most migrations.

Step 4: Bring the Media Across

Images in content pointed at https://blog.example.com/wp-content/uploads/2019/04/diagram-1024x576.png — often a resized variant WordPress had generated. The script rewrote each to the original upload under /media/2019/04/diagram.png and copied the originals into the Eleventy input, where @11ty/eleventy-img generates responsive variants at build time. Stripping the -1024x576 suffix and choosing the original gave better quality for the same rendered size. 4,200 attachments became 2,900 used images; 1,300 were never referenced by any published post and were left behind after a check.

Steps from WordPress export to Eleventy site Five steps. Parse the WXR export into posts, pages and attachments. Convert shortcodes then HTML to Markdown with Turndown, keeping complex blocks as HTML. Write Markdown files with front matter including the WordPress ID and permalink. Rewrite image URLs to originals and process them with eleventy-img. Generate redirects from IDs, attachment pages and old patterns, then compare against the live site crawl. The conversion pipeline Parse WXR posts, pages, media Shortcodes + Turndown complex blocks kept as HTML Write Markdown front matter incl. wpId Media to originals eleventy-img variants Redirects + parity against live crawl
The script ran in 41 seconds over the whole export and was rerun many times as rules improved.

Step 5: Taxonomies and Comments

Categories and tags became Eleventy collections built from front matter, with tag pages at /tag/<slug>/ and category pages at /category/<slug>/, matching WordPress's default bases so those URLs survived unchanged. WordPress had 640 tags, 410 of them used once; tags used once were dropped from tag page generation (the posts kept them in front matter), and their old tag URLs redirected to the closest category.

Comments were exported into src/_data/comments/<wpId>.json and rendered as a static, read-only thread under each post. New comments were closed; a link invites readers to reply by email. 9,600 comments added 3 seconds to the build.

Step 6: Redirects

Three kinds of old URLs needed redirects, all generated from the export:

  • /?p=123 query-string URLs, still linked from old emails, to each post's permalink by wpId. Query strings cannot be matched by every host's redirect file; on Cloudflare a small Worker handled them, as in Configuring Redirects on Cloudflare Pages.
  • Attachment pages (/2019/04/post/diagram/) to the parent post.
  • /feed/ and category feeds to the new Atom feeds.

The redirect file had 2,140 entries.

Step 7: Check Parity Against the Live Site

Before switching DNS, every URL from the live crawl was requested from the new build's preview:

while read url; do
  code=$(curl -s -o /dev/null -w '%{http_code}' "https://preview.example.pages.dev${url}")
  echo "$code $url"
done < live-urls.txt | grep -v '^200\|^301' > problems.txt

The first run found 118 problems, mostly posts whose WordPress slug contained percent-encoded characters (caf%c3%a9). Decoding slugs in the converter fixed all but four, which got redirects. A text comparison of title, word count and image count per post, as in Auditing Content Parity After a Migration, found 9 posts where a plugin's shortcode had been silently dropped.

Replacing What WordPress Did at Runtime

A WordPress site does things on every request that a static site must do at build time or hand to a service. The blog's inventory of runtime features, and what replaced each:

  • Search. WordPress's built-in search became Pagefind, run over the Eleventy output after each build. It indexes 1,430 posts into fragments of about 40 KB per query, with no server.
  • Contact form. A Contact Form 7 page became a static form posting to a small Cloudflare Worker that forwards submissions by email, as in Adding a Contact Form with Cloudflare Workers.
  • Related posts. A plugin computed them per request from tags. An Eleventy collection now computes them at build time from shared tags and categories, weighted by recency; the build cost is about 2 seconds.
  • Scheduled posts. WordPress published future-dated posts automatically. Eleventy excludes posts with a future date at build time, and a daily scheduled build publishes them, as in Scheduled Content Publishing With GitHub Actions.
  • Editing. The two regular writers moved to editing Markdown in a Git-based CMS with a preview, which they found closer to the WordPress editor than editing files directly.

Listing these before starting avoided the most common late surprise in WordPress migrations: discovering, after the switch, a feature that only existed because a plugin was quietly running on every page view.

Measured Impact

MeasureWordPressEleventyTool
TTFB, uncached post, mobile1.1 s0.09 s (CDN)WebPageTest
LCP, post page, mobile p753.4 s1.5 sCrUX, 28 days
Page weight, typical post1.9 MB310 KBDevTools
Hosting cost per month$45 managed WP$0 (free tier)Invoices
Full build—38 sCI logs
Live URLs not resolving after switch—0Crawl + edge 404 log
Field LCP and page weight before and after the migration Paired bars. LCP at the 75th percentile on mobile went from 3.4 to 1.5 seconds. Page weight of a typical post went from 1,900 to 310 kilobytes. Time to first byte went from 1.1 seconds to 0.09 seconds. Field performance before and after WordPress Eleventy LCP p75 (s) 3.4 1.5 Page weight (MB) 1.9 0.31 TTFB (s) 1.1 0.09
CrUX 28-day data before the switch and eight weeks after; page weight from DevTools on the same post.

Traffic held steady through the switch: organic clicks in the four weeks after were 102% of the four weeks before, because every URL kept working.

Pitfalls & Rollback

  • Using the resized image URLs from content. Map back to originals and generate variants yourself.
  • Local dates instead of GMT. Posts move to the wrong month and URLs change.
  • Forgetting ?p= and attachment URLs. They carry old links and email traffic.
  • Encoded slugs. Decode them, or the new URLs will not match.
  • Rollback: keep WordPress running read-only on a subdomain for a month; DNS can point back within minutes if needed.

Conclusion

A WordPress export converts to Eleventy with a script you can rerun until it is right: parse the WXR, convert shortcodes and HTML to Markdown, keep the WordPress ID and permalink in front matter, map media to originals, and generate redirects for every old URL pattern. For a 1,430-post blog that meant zero broken URLs after the switch, mobile LCP from 3.4 to 1.5 seconds, and no hosting bill.

FAQ

Which WordPress export should I use for a migration?

The WXR file from Tools then Export, with All content selected. It contains posts, pages, categories, tags, authors, comments and attachment records with their original URLs. Media files themselves are not in the export, so download the uploads folder separately over SFTP or with a plugin, or fetch each attachment URL during conversion.

How do I keep WordPress URLs after moving to Eleventy?

Generate each post's permalink in Eleventy from its WordPress slug and date in the same pattern as the WordPress permalink setting, for example slash year slash month slash slug. For URLs that cannot be reproduced, such as attachment pages and query-string URLs like question mark p equals 123, write redirects at the host from the IDs and slugs in the export.

What happens to Gutenberg blocks in the conversion?

The export contains the rendered HTML with block comments. Converting it with Turndown gives clean Markdown for paragraphs, headings, lists, images, quotes and code. Complex blocks such as galleries, columns, embeds and plugin blocks need specific rules or are left as HTML inside the Markdown, which Eleventy renders as is.

Can comments be kept on a static site?

Existing comments can be converted to data files and rendered statically under each post, which preserves the discussion. New comments need a third-party or self-hosted service, or a form that submits to an email or a pull request. Many migrated blogs keep the archive read-only and close new comments.

Filed underHow-tosEleventy