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 -ror 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.
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.
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=123query-string URLs, still linked from old emails, to each post's permalink bywpId. 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
| Measure | WordPress | Eleventy | Tool |
|---|---|---|---|
| TTFB, uncached post, mobile | 1.1 s | 0.09 s (CDN) | WebPageTest |
| LCP, post page, mobile p75 | 3.4 s | 1.5 s | CrUX, 28 days |
| Page weight, typical post | 1.9 MB | 310 KB | DevTools |
| Hosting cost per month | $45 managed WP | $0 (free tier) | Invoices |
| Full build | — | 38 s | CI logs |
| Live URLs not resolving after switch | — | 0 | Crawl + edge 404 log |
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.
Related
- Parent: Migrating Between Static Site Generators — planning any migration.
- Migrating WordPress to a Static Site Generator — the decision and the options.
- Converting Front Matter at Scale During Migration — front matter conversion in depth.
- Responsive Images with srcset in Eleventy — the image pipeline after conversion.
- Fixing Duplicate Permalink Errors in Eleventy — collisions that appear when imported slugs clash.