Web

Moving a WordPress Blog to Static HTML Without Losing Traffic

By Nawawi JamiliPublished 8 min read

This site ran on WordPress from 2009 until 2026. For most of that time it was not much of a blog. It had a few short Bash snippets I had written, hidden among roughly 1,600 posts that a plugin had imported automatically from news feeds. When I decided to relaunch it, I rewrote the original posts as proper tutorials, dropped the imported ones, and moved the whole thing to static HTML files.

The hard part was not generating HTML but keeping seventeen years of links and bookmarks working. Here is how, with snippets simplified from the real build and all of them tested.

Why static

The site has no comments, forms or visitor logins. WordPress was running PHP, a database and a stack of plugin updates to serve pages that change only when I publish. Static files are fast anywhere and leave nothing to patch on the server. I kept WordPress, though, running locally as the editor: I still write posts there, and a build step turns the local database into HTML.

Step 1: export the content with WP-CLI

WP-CLI's eval-file runs a PHP file with WordPress fully loaded, so you can use the normal API (get_posts(), get_permalink(), the the_content filter) and print JSON. A stripped-down version of my exporter:

php
<?php
// wp eval-file export.php --skip-plugins > site.json
remove_filter( 'the_content', 'wptexturize' );
$out = array( 'posts' => array() );
$posts = get_posts( array(
	'post_type'   => 'post',
	'post_status' => 'publish',
	'numberposts' => -1,
) );
foreach ( $posts as $post ) {
	setup_postdata( $GLOBALS['post'] = $post );
	$out['posts'][] = array(
		'id'         => $post->ID,
		'url'        => get_permalink( $post ),
		'title'      => get_the_title( $post ),
		'date'       => get_post_time( 'c', false, $post ),
		'modified'   => get_post_modified_time( 'c', true, $post ),
		'content'    => apply_filters( 'the_content', $post->post_content ),
		'categories' => wp_list_pluck( get_the_category( $post->ID ), 'slug' ),
	);
}
wp_reset_postdata();
echo wp_json_encode( $out, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
terminal
$ wp eval-file export.php --skip-plugins > site.json
$ jq '.posts | length' site.json
36
$ jq -c '.posts[0] | {id,url,title,categories}' site.json
{"id":2094,"url":"/2026/10/php-string-functions-in-bash/","title":"PHP String Functions in Bash: A Cheat Sheet","categories":["bash"]}

A few details that took me time to learn:

  • Run the content through the_content. The raw post_content has no paragraphs (WordPress adds them with wpautop at display time) and unexpanded shortcodes.
  • Remove wptexturize if you publish code. It turns straight quotes into curly ones and -- into a dash, which breaks every shell command a reader copies. My posts already contain proper <p> tags, so I remove wpautop too.
  • --skip-plugins keeps plugins from decorating the content with share buttons and tracking scripts.
  • Export the permalink, not the slug. get_permalink() gives the exact URL WordPress served, which is what the static site must reproduce.
  • Export the post ID. You will need it for old shortlinks (see below).

Media is simpler: I copy the dated upload folders (wp-content/uploads/2009/ and so on) with rsync, using include and exclude rules so plugin working folders are not copied.

Step 2: generate pages at the same URLs

The generator is a single Node.js script with no dependencies. The core idea fits in a few lines: take the path from each exported permalink and write the page to path/index.html. Any web server then serves /2009/04/strlen/ from 2009/04/strlen/index.html, exactly the URL WordPress used.

javascript
const fs = require('fs');
const path = require('path');
const data = JSON.parse(fs.readFileSync('site.json', 'utf8'));
const DIST = 'dist';
function writePage(urlPath, html) {
  const file = path.join(DIST, urlPath, 'index.html');
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, html);
}
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
  .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
for (const post of data.posts) {
  // /2009/04/strlen/ -> /2009/04/strlen/
  post.path = new URL(post.url).pathname;
  writePage(post.path, `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<title>${esc(post.title)}</title>
<link rel="canonical" href="/${post.path}">
</head><body><h1>${esc(post.title)}</h1>
${post.content}
</body></html>
`);
}
terminal
$ node build.js
36 posts written
$ find dist -name index.html | sort | head -3
dist/2009/04/basename/index.html
dist/2009/04/dirname/index.html
dist/2009/04/_execq/index.html

The real build adds templates, listings, date archives and syntax highlighting, and rewrites absolute links in post content to root-relative ones so the output works on a preview server too. But the URL rule stays this simple. Keeping the permalink structure identical means kept posts need no redirects at all, which is the cheapest option of all.

Step 3: redirect everything that went away

Dropping 1,600 posts means 1,600 URLs that external sites, old bookmarks and search engines still point to. Some removed URLs follow patterns, so a handful of Apache rewrite rules in .htaccess cover all of them:

apache
ErrorDocument 404 /404.html
RewriteEngine On
RewriteBase /
# WordPress category and feed URLs
RewriteRule ^category/(bash|mysql)/(.*)$ /$1/$2 [R=301,L]
RewriteRule ^category(/.*)?$ / [R=301,L]
RewriteRule ^(.+/)?feed/?$ / [R=301,L]
# Dated post URLs with no file here: the post was removed
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^\d{4}/\d{2}/(?!page/)[^/]+/?$ / [R=301,L]

The last rule is the important one. Any /YYYY/MM/slug/ URL that does not exist as a directory in the build was one of the removed posts. So it is sent to the home page with a permanent redirect. The two RewriteCond lines make sure kept posts are never touched, and the negative lookahead leaves /YYYY/MM/page/2/ archive pages to their own rule. The real file has a few more rules of the same shape: very old /index.php/2009/04/24/slug/ permalinks, removed tags and authors, and date archives that no longer exist.

I considered 410 Gone instead. Google may treat removed pages that redirect to the home page as soft 404s. But those imported news items were never going to rank again. And a visitor following a very old link lands on a working page instead of an error.

To test the rules, I ran Apache locally as an ordinary user with a minimal config pointing at the build. Before deleting the imported posts, I had saved a list of every old post URL. I sent all 1,640 of them to Apache:

bash
#!/bin/bash
# check-old-urls.sh BASE_URL < old-urls.txt: status of every old URL
base=$1
while IFS= read -r path; do
    curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' "$base$path"
done | awk '{ n[$1 " " $2]++ } END { for (k in n) print n[k], k }' | sort -rn
terminal
$ ./check-old-urls.sh http://127.0.0.1:8733 < old-urls.txt
1622 301 http://127.0.0.1:8733/
18 200

Eighteen kept posts answer 200 at their original URLs, and every one of the 1,622 removed posts gets a 301. No old URL ends in a 404. The project's own checker runs the same coverage test against the build directory, so a future rule change cannot quietly break it.

Step 4: /?p= and /?s= in the browser, not the server

WordPress has two kinds of URL that live in the query string: shortlinks like /?p=21 and searches like /?s=trim. Both appear in old forum posts and emails. The obvious fix is a RewriteCond %{QUERY_STRING} rule. I deliberately avoided it.

The reason is caching. All these URLs share the path /. Only the query string differs. The site may one day sit behind a CDN or caching proxy that is configured to ignore query strings. That is a common setting for static sites, because it improves the hit rate. Then a cached 301 for /?p=21 can be stored under plain / and served to everyone who visits the home page. A redirect that depends only on the path does not have this problem.

So the server returns the home page for any query string, and a few lines of JavaScript on every page do the lookup:

javascript
// Old WordPress query URLs: /?s=keyword and /?p=123
var params = new URLSearchParams(location.search);
if (params.get('s')) {
  location.replace('/search/?q=' + encodeURIComponent(params.get('s')));
} else if (/^\d+$/.test(params.get('p') || '')) {
  fetch('/assets/shortlinks.json')
    .then(function (r) { return r.json(); })
    .then(function (map) { location.replace(map[params.get('p')] || '/'); })
    .catch(function () {});
}

The build writes the ID map from the exported post IDs:

javascript
const ids = Object.fromEntries(data.posts.map((p) => [p.id, p.path]));
fs.writeFileSync(path.join(DIST, 'assets/shortlinks.json'), JSON.stringify(ids));

I tested the script in Node with a stub location object and fetch pointed at the local Apache:

output
?p=21 -> /2009/04/strlen/
?p=99999 -> /
?s=trim%20whitespace -> /search/?q=trim%20whitespace

Unknown IDs go to the home page, and other parameters are ignored. location.replace() keeps the old URL out of the back-button history. Google renders JavaScript and can follow these redirects, but they matter far more for human visitors following old links than for rankings.

Step 5: search without a server

WordPress search needs PHP and a database. On a site with a few dozen long articles, a prebuilt index loaded in the browser is enough:

javascript
const text = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const index = data.posts.map((p) => ({ t: p.title, u: p.path, b: text(p.content) }));
fs.writeFileSync(path.join(DIST, 'assets/search-index.json'), JSON.stringify(index));

The search page fetches the file on first use and scores each article, with title matches counting most and body matches least. With the full text of all articles the index is about 135 KB before compression, and it is fetched only by people who search. The URL carries a hash of the content (search-index.json?v=1a2b3c4d) so a browser never keeps using an old index after a rebuild. The /search/ page is marked noindex and disallowed in robots.txt.

Step 6: sitemaps, robots.txt and ads.txt

The old site had sitemaps from an SEO plugin at /sitemap_index.xml, with per-type files like post-sitemap.xml. Search engines already knew those URLs, so the build writes files with the same names. Each entry uses the post's modified date as lastmod:

javascript
const urls = data.posts.map((p) =>
  `<url><loc>${SITE}${p.path}</loc><lastmod>${p.modified}</lastmod></url>`);

Removed posts are simply absent from the sitemap. robots.txt points at the sitemap index.

The site carries AdSense, and ad networks fetch /ads.txt from the site root to check which sellers are authorised. Lose it in a migration and revenue can drop quietly. The build generates it from the config file that also holds the publisher ID for the templates, so the two cannot get out of sync. Verification files live in a static/ folder copied into the build unchanged.

Step 7: a useful 404 page

Some URLs will still not match anything: typos, truncated links, pages that never existed. Apache serves /404.html through ErrorDocument, with a real 404 status:

terminal
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8733/no-such-page/
404

The page has a search box and links to the Bash function articles, so a lost visitor has somewhere to go. It is marked noindex. On nginx, use error_page 404 /404.html; with try_files $uri $uri/index.html =404; and translate the rewrite rules.

What I would do again

  • Save every old URL before you start. That list became the test suite for the whole migration.
  • Keep the permalink structure. Every URL you do not change is a redirect you do not have to maintain.
  • Prefer redirects that depend only on the path. Handle query-string URLs in the page.
  • Test the real server config locally. Apache runs without problems as an ordinary user on a high port with a twenty-line config file.
  • Recheck on every change. One make update exports, builds and runs the checker, which exits non-zero if any old URL is left uncovered.

For the caching headers the static build sends (a year for fingerprinted assets, an hour for HTML), see my notes on Cache-Control.