Migrating a WordPress Site to Next.js Static Export
You’re staring at the WordPress admin panel. Twenty-three plugin updates pending. A PHP version warning you’ve been ignoring for two months. PageSpeed score sitting in the low 40s for a site that’s mostly text and a few images. You’ve read the arguments. You’ve weighed the options. You’ve decided.
Now what?
This is the full playbook for getting your content out of WordPress, converting it to markdown, handling the image mess, standing up a Next.js static export, and setting up redirects so you don’t torch your search rankings in the process. No headless CMS intermediary. No dependency on the WordPress REST API. A clean break.
The migration itself is a weekend project. The months you spent thinking about it were the expensive part.
Before You Touch Any Code
Start with an honest inventory. Open your WordPress admin and count: how many posts, how many pages, how many custom post types? Look at your active plugins and ask which ones are solving real problems versus solving WordPress problems. Contact forms, analytics, SEO meta tags, caching, security hardening — most of these either disappear entirely on a static site or get replaced by something simpler.
Next, export your content. Go to Tools > Export > All Content in the WordPress admin. This gives you an XML file containing every post, page, and piece of metadata. Keep this file — it’s your source of truth for the migration.
Finally, crawl your live site to capture every URL that exists. You’ll need this list for redirects later.
wget --spider --recursive --no-verbose --output-file=crawl.log https://yoursite.com
grep -oP 'https://yoursite\.com[^\s]+' crawl.log | sort -u > urls.txt
That urls.txt file is important. Every URL in it represents a page that Google has indexed, that someone might have bookmarked, that another site might be linking to. We’ll come back to it.
Extracting and Converting Content
The WordPress XML export contains everything, but it’s wrapped in WordPress-specific markup that’s useless outside of WordPress. You need to convert it to clean markdown files with YAML frontmatter.
The wordpress-export-to-markdown tool handles the heavy lifting:
npx wordpress-export-to-markdown --wizard false \
--input export.xml \
--output content/posts \
--year-folders false \
--month-folders false \
--post-folders false \
--save-attached-images true \
--save-scraped-images true
This generates one .md file per post with frontmatter and downloads all referenced images. It’s not perfect — you’ll find Gutenberg block comments (<!-- wp:paragraph -->), leftover shortcodes, and HTML entities scattered through the output. Write a quick cleanup script:
import fs from "fs";
import path from "path";
import matter from "gray-matter";
const postsDir = path.join(process.cwd(), "content/posts");
fs.readdirSync(postsDir)
.filter((f) => f.endsWith(".md"))
.forEach((file) => {
const filePath = path.join(postsDir, file);
const raw = fs.readFileSync(filePath, "utf8");
const { data, content } = matter(raw);
const cleaned = content
.replace(/<!--\s*\/?wp:\w+.*?-->/g, "") // Gutenberg comments
.replace(/\[caption[^\]]*\](.*?)\[\/caption\]/g, "$1") // captions
.replace(/ /g, " ")
.replace(/\n{3,}/g, "\n\n"); // collapse blank lines
const frontmatter = {
title: data.title,
slug: file.replace(/\.md$/, ""),
excerpt: (data.description || data.excerpt || "").slice(0, 155),
publishedAt: new Date(data.date).toString(),
draft: false,
};
fs.writeFileSync(filePath, matter.stringify(cleaned.trim(), frontmatter));
});
Run it, then skim the output files. You’re looking for anything the automated conversion mangled — embedded videos, complex tables, shortcodes from plugins that didn’t have a markdown equivalent. Those posts need manual attention. The rest should be clean.
Don’t use the WordPress REST API as your content source. That’s headless WordPress, and it means you’re still running a WordPress instance, still patching plugins, still paying for hosting. Export once, convert to files, shut it down.
Handling Images
WordPress image handling is the messiest part of any migration. Every upload generates multiple sizes. Paths are buried in date-based directories (/wp-content/uploads/2024/03/hero-image-1024x768.jpg). Srcset markup is baked into the HTML.
The converter downloads the images, but you need to normalize them. Flatten everything into a single directory, convert to .webp, and keep dimensions reasonable:
mkdir -p public/media
for img in content/images/*.{jpg,jpeg,png}; do
name=$(basename "${img%.*}")
cwebp -q 80 -resize 1200 0 "$img" -o "public/media/${name}.webp"
done
Then update your markdown files to reference the new paths. A simple find-and-replace handles most of it — swap any path matching /wp-content/uploads/... with /media/filename.webp.
Don’t preserve WordPress’s date-based upload directory structure. It served no purpose then and it serves even less now. One flat directory. Descriptive filenames. Done.
Setting Up the Next.js Static Export
If you’re starting from scratch, the basic Next.js setup is straightforward. The full details of building a markdown-driven content layer are covered in Markdown-Driven Content, and the deployment pipeline is in How to Deploy to Render. Here’s the migration-specific setup.
Your next.config.ts:
const nextConfig = {
output: "export",
trailingSlash: true,
images: { unoptimized: true },
};
export default nextConfig;
The critical piece is generateStaticParams in your post route, which tells Next.js to build a page for every markdown file at build time:
import { getAllPosts, getPostBySlug } from "@/lib/content";
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return (
<article>
<h1>{post.title}</h1>
<time>{post.publishedAt}</time>
<div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
</article>
);
}
Keep the content layer simple. gray-matter for frontmatter, remark for markdown processing, filesystem reads at build time. Don’t reach for a content framework — Contentlayer is unmaintained, and for a migrated WordPress site, the basics are all you need.
Redirects: Don’t Tank Your SEO
This is the section most migration guides skip, and the one that costs the most when you get it wrong.
WordPress URL structures vary: /2024/03/15/my-post/, /category/my-post/, /my-post/. Your new Next.js site probably uses /posts/my-post/. Every old URL needs a 301 redirect to its new location, or you’re throwing away every inbound link and every Google ranking you’ve built.
For static sites on Render or Netlify, use a _redirects file — Next.js middleware requires a server, which you don’t have with static export.
Generate it from your crawl data:
import fs from "fs";
const urls = fs.readFileSync("urls.txt", "utf8").split("\n").filter(Boolean);
const domain = "https://yoursite.com";
const redirects = urls
.map((url) => url.replace(domain, ""))
.filter((path) => path.startsWith("/") && path !== "/")
.map((oldPath) => {
const slug = oldPath.replace(/\/$/, "").split("/").pop();
return `${oldPath} /posts/${slug}/ 301`;
})
.join("\n");
fs.writeFileSync("public/_redirects", redirects);
Review the output. Some URLs won’t map cleanly — category pages, tag archives, paginated indexes. Handle those manually. The goal is zero unhandled 404s for any URL that existed on the old site.
Redirects are not optional. Every 404 on an old URL is a broken bookmark, a dead link on someone else’s site, a ranking you’re giving back to Google. Spend the hour.
Go Live Checklist
- Run
npm run buildand verify every post renders without errors - Check image paths — broken images are the most common migration bug
- Test redirects with
curl -I https://yoursite.com/old-url/and confirm you get 301s - Update DNS to point at your new host
- Submit your new sitemap to Google Search Console
- Monitor your 404 logs for the first week — every 404 is a redirect you missed
- Set up basic uptime monitoring (UptimeRobot, free tier is fine)
The first week after migration is the debugging window. After that, you’re done. No more plugin updates. No more PHP patches. No more PageSpeed fights. Just files, Git, and a site that loads in under a second.
Static Signal is published by Neuron Web Development.