WordPress is still the right answer for plenty of projects. It is also the wrong answer for a growing share of the work we are asked to do: marketing sites that need real performance, content platforms with structured data, multi channel publishing, and teams that want their editors and their developers to stop fighting over the same theme files. When the time comes to move off, the migration is more than swapping a database. This is the playbook we run.
1. Audit before you touch anything
Skipping the audit is the most common reason migrations slip. Before you choose tools, before you write a line of code, inventory what you actually have.
- Every content type, including custom post types and taxonomies
- Every Advanced Custom Fields group, its fields, and where the fields render
- Every active plugin, and what it produces (forms, redirects, SEO metadata, sliders, e-commerce, membership)
- The theme template hierarchy and which templates actually get used
- Media library size and patterns (hot linked images, oversized originals, missing alt text)
- Permalink structure and any non default URL patterns
- Traffic patterns from analytics, so you know which 200 URLs out of 12,000 actually matter
The deliverable is a spreadsheet or doc that lists each content type, its fields, its volume, and what it maps to in the new system. If you cannot fill it in, you are not ready to migrate.
2. Choose the destination
There is no universally correct headless CMS. We pick based on the team, the content model, and the deployment story. Our short list:
- Payload CMS when the team wants full control, self hosted infrastructure, code first schema, and tight integration with a Node backend. Good fit when you have engineers who will treat the CMS as part of the app.
- Sanity when content modeling is complex, the editing experience matters, and the team values real time collaboration. Portable Text is excellent for structured rich content.
- Contentful when the buyer is enterprise, the team needs role based access at scale, and the workflow features justify the cost.
- Storyblok when visual editing and a marketing team driven workflow are the priority.
We have written a longer rubric on how we score CMS choices in our process. The short version: pick the system your editors will actually use, not the one with the prettiest landing page.
3. Model the content
Translating a WordPress site to a structured CMS is the step where you fix the data sins of the past. WordPress lets editors stuff anything into the post body. A headless CMS will reward you for separating fields.
A typical mapping looks like:
postto aArticlecollection with explicit fields for title, slug, summary, author reference, hero image, body, SEO overrides, and publish datepageto aPagecollection with a block based body field- ACF flexible content blocks become a discriminated block array in the new CMS
- Categories and tags become reference fields or taxonomies in the new system
- Custom user roles map to CMS roles or stay in your auth provider
Resist the urge to recreate the WordPress shape one to one. The point of migrating is to have better content. Take the time to remodel.
4. Export from WordPress
Two viable paths. The REST API is cleaner and includes ACF data if the right plugin is installed. A SQL dump is faster for huge sites but means you write more transformation code.
Here is a Node script we use to pull posts from a WordPress REST API. It paginates, includes embedded media and ACF fields, and writes one JSON file per post.
// scripts/export-wp.ts
import fs from "node:fs/promises";
import path from "node:path";
const WP_BASE = process.env.WP_BASE_URL!;
const OUT = "exports/posts";
const PER_PAGE = 100;
type WpPost = {
id: number;
slug: string;
date_gmt: string;
modified_gmt: string;
title: { rendered: string };
content: { rendered: string };
excerpt: { rendered: string };
acf?: Record<string, unknown>;
_embedded?: { "wp:featuredmedia"?: Array<{ source_url: string; alt_text: string }> };
};
async function fetchPage(page: number): Promise<WpPost[]> {
const url = `${WP_BASE}/wp-json/wp/v2/posts?per_page=${PER_PAGE}&page=${page}&_embed=1`;
const res = await fetch(url);
if (res.status === 400) return []; // past last page
if (!res.ok) throw new Error(`WP error ${res.status} on page ${page}`);
return res.json();
}
async function main() {
await fs.mkdir(OUT, { recursive: true });
let page = 1;
while (true) {
const batch = await fetchPage(page);
if (batch.length === 0) break;
for (const post of batch) {
const file = path.join(OUT, `${post.slug}.json`);
await fs.writeFile(file, JSON.stringify(post, null, 2));
}
console.log(`Saved page ${page} (${batch.length} posts)`);
page += 1;
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Run that, get a directory of JSON, commit it to a migration repo. You now have a stable snapshot to work against without hammering the live WP site.
5. Transform the content body
This is the hardest step. WordPress stores post body as HTML with inline styles, shortcodes, Gutenberg block comments, and whatever the previous theme injected. Your destination CMS probably wants Markdown, Portable Text, Lexical, or a structured block array.
We use rehype and unified for HTML normalization. The pipeline:
- Parse the HTML to a tree
- Strip inline styles, deprecated tags, and theme specific wrappers
- Convert shortcodes to structured blocks (a
[gallery]shortcode becomes aGalleryblock) - Resolve internal links from absolute WordPress URLs to relative paths
- Download referenced images and rewrite the URLs to your new media host
- Serialize to the destination format
Portable Text is our preferred target when the CMS supports it (Sanity). It is structured, easy to render across platforms, and survives schema changes. Markdown is fine for simple article content. Lexical or Slate state is necessary if you are landing in a CMS that uses a specific rich text editor.
Plan to spend a third of the project budget on this step. The script will run dozens of times before the output looks clean.
6. Import to the new CMS
Every headless CMS has a write API. Loop over your transformed content, create or upsert records, and keep a mapping of old WP IDs to new CMS IDs so you can resolve internal references.
A few non obvious details:
- Run the import idempotently. You will run it more than once.
- Import authors and taxonomies first, then articles that reference them.
- Throttle the writes. Most CMS APIs have rate limits, and you will hit them.
- Validate after import. A diff between the source count and the destination count catches half of all migration bugs.
7. Redirects: preserve every permalink
This is where SEO migrations go to die. WordPress permalink structures vary, and an unplanned URL change costs traffic for months.
The rule: every public WordPress URL gets a 301 to its new home, including category archives, tag pages, author pages, and attachment pages if they were indexed. Build the redirect map during the import step, then load it into your hosting layer (Next.js middleware, Vercel redirects config, Cloudflare rules, or NGINX).
// next.config.mjs (excerpt)
export default {
async redirects() {
const map = (await import("./redirects.json", { with: { type: "json" } })).default;
return map.map((r) => ({
source: r.from,
destination: r.to,
permanent: true,
}));
},
};
Crawl the old site with a tool like Screaming Frog before cutover, store the URL list, and use it as a checklist after launch.
8. The frontend rebuild
A headless CMS without a frontend is a database with a nice editor. We rebuild on Next.js in almost every case, because the App Router maps cleanly to dynamic content, ISR or on demand revalidation handles the publish loop, and the resulting site is faster than any WordPress theme.
Structure the frontend so a content type maps to a route group, a block in the CMS maps to a React component, and a preview mode hits the CMS draft API. Treat the rendering layer as the boring part. The content modeling is where the value is.
9. The cutover
Soft launch, never hard. Our checklist:
- Deploy the new site to a staging domain. Run it for at least a week with real editors publishing in parallel.
- Crawl staging end to end. Compare against the redirect map. Fix every miss.
- Lower the DNS TTL on the WordPress domain a few days in advance so the switch propagates quickly.
- Freeze WordPress edits the day of cutover. Run a final delta import for anything touched in the freeze window.
- Switch DNS during a low traffic window. Watch error rates and crawl stats for the first 48 hours.
- Leave WordPress online but locked for two to four weeks so you can compare anything weird against the source of truth.
Timeline expectations
Real numbers from the projects we have shipped:
- A small WordPress site, a few hundred posts, no custom plugins, simple ACF: two to four weeks end to end.
- A mid sized site with custom post types, a content team, and SEO history to protect: six to ten weeks.
- A large site with custom plugins, e-commerce, membership, or multi site networks: eight to sixteen weeks, sometimes longer.
The variable is not the post count. It is the number of bespoke behaviors WordPress is doing that someone has to consciously decide to rebuild, replace, or drop.
If you are looking at a migration and trying to scope it honestly, talk to us. We are a web development team in Toronto that has shipped this work for content-heavy sites across Canada and the US, and we will tell you what is actually involved before you commit.
Tags