Zero Downtime CMS Migration: WordPress to Next.js Cutover Playbook
I've migrated over a dozen WordPress sites to Next.js in production, and I'll tell you something that might surprise you: the actual code migration isn't the hard part. It's the cutover. That terrifying moment when you flip the switch and pray nothing breaks. The good news? With the right playbook, you can make that moment boring. And in operations, boring is exactly what you want.
This is the playbook we use at Social Animal for production cutovers. It's not theoretical — it's built from real migrations where real revenue was on the line. We're talking e-commerce sites doing $50K/day, content publishers with millions of monthly pageviews, and SaaS marketing sites where 5 minutes of downtime means the CEO is calling your cell phone.
Table of Contents
- Why Zero Downtime Matters More Than You Think
- The Migration Architecture Overview
- Phase 1: Content Migration and API Layer
- Phase 2: Building the Next.js Application
- Phase 3: Parallel Deployment Setup
- Phase 4: Blue-Green Deployment Configuration
- Phase 5: DNS Switchover Strategy
- Phase 6: The Cutover Checklist
- Phase 7: Post-Cutover Monitoring and Rollback
- Common Failure Modes and How to Prevent Them
- FAQ

Why Zero Downtime Matters More Than You Think
Let's put some numbers on this. Google's research from 2024 shows that a 1-second delay in page load costs roughly 7% in conversions. Now imagine your site is just... gone. Even for 5 minutes.
Here's what's actually at stake:
| Downtime Duration | Revenue Impact (for $10K/day site) | SEO Impact | User Trust Impact |
|---|---|---|---|
| 5 minutes | ~$35 lost | Minimal if isolated | Low |
| 30 minutes | ~$208 lost | Googlebot may notice | Moderate |
| 2 hours | ~$833 lost | Crawl errors in GSC | High |
| 24 hours | $10,000+ lost | Deindexing risk | Severe |
But it's not just revenue. Search engines are constantly crawling. If Googlebot hits your site during a migration and gets 500 errors, those URLs can drop from the index within hours. I've seen sites lose 40% of their organic traffic because someone did a "quick migration" over lunch.
The goal isn't just zero downtime. It's zero visible change to users and crawlers during the transition.
The Migration Architecture Overview
Before we get into the phases, let's look at the architecture we're targeting. The fundamental pattern is running both systems in parallel, then shifting traffic atomically.
┌─────────────────┐
│ Cloudflare / │
│ Load Balancer │
└────────┬────────┘
│
┌────────┴────────┐
│ Traffic Router │
│ (weight-based) │
└────┬───────┬────┘
│ │
┌──────────┴──┐ ┌──┴──────────┐
│ WordPress │ │ Next.js │
│ (Blue) │ │ (Green) │
│ Origin │ │ on Vercel │
└──────────┬──┘ └──┬──────────┘
│ │
┌──────────┴──┐ ┌──┴──────────┐
│ MySQL DB │ │ Headless CMS │
│ │ │ (Sanity/etc) │
└─────────────┘ └─────────────┘
The key insight: you're not just migrating a frontend. You're migrating the content layer, the rendering layer, and the delivery layer — and they can each be migrated independently.
Phase 1: Content Migration and API Layer
This is where most teams start wrong. They try to build the Next.js frontend first and then figure out the content later. Don't do this. Start with the content.
Choosing Your Headless CMS
Your WordPress content needs a new home. The choice matters a lot for migration complexity:
| CMS | Migration Ease from WP | Real-Time Sync Possible | Pricing (2025) | Best For |
|---|---|---|---|---|
| Sanity | High (structured content maps well) | Yes, via webhooks | Free tier, then $99/mo | Complex content models |
| Contentful | Medium (field mapping required) | Yes | $300/mo for Team | Enterprise teams |
| Strapi | High (similar DB-backed model) | Yes | Self-hosted free, Cloud from $29/mo | Full control |
| WordPress REST API | N/A (keep as headless) | Already synced | Existing hosting costs | Quick wins |
| Payload CMS | High | Yes | Self-hosted free | Developer-first teams |
We cover headless CMS selection in depth on our headless CMS development capabilities page, but the short version: for most WordPress migrations, Sanity or Payload CMS gives you the best migration path.
Setting Up Content Sync
Here's the critical part: during the parallel run phase, content needs to exist in both systems. You have two strategies:
Strategy A: One-time migration + freeze Migrate all content to the new CMS, then freeze WordPress editing. This works for small sites but breaks down when editors need to keep publishing.
Strategy B: Continuous sync (recommended) Set up a sync pipeline that replicates WordPress changes to your new CMS in near-real-time.
// Example: WordPress webhook handler that syncs to Sanity
// This runs as a serverless function (Vercel/AWS Lambda)
import { createClient } from '@sanity/client';
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: 'production',
token: process.env.SANITY_WRITE_TOKEN,
apiVersion: '2025-01-01',
useCdn: false,
});
export async function POST(request) {
const payload = await request.json();
const { post_id, post_title, post_content, post_status } = payload;
if (post_status !== 'publish') return new Response('Skipped', { status: 200 });
try {
await sanity.createOrReplace({
_id: `wp-${post_id}`,
_type: 'post',
title: post_title,
body: convertGutenbergToPortableText(post_content),
migratedFrom: 'wordpress',
wpId: post_id,
_updatedAt: new Date().toISOString(),
});
return new Response('Synced', { status: 200 });
} catch (error) {
console.error('Sync failed:', error);
return new Response('Sync error', { status: 500 });
}
}
You'll also need the WordPress side. We use a simple plugin that fires on save_post:
// wp-content/plugins/headless-sync/headless-sync.php
add_action('save_post', function($post_id, $post) {
if (wp_is_post_revision($post_id)) return;
wp_remote_post(SYNC_ENDPOINT_URL, [
'body' => json_encode([
'post_id' => $post_id,
'post_title' => $post->post_title,
'post_content' => $post->post_content,
'post_status' => $post->post_status,
]),
'headers' => [
'Content-Type' => 'application/json',
'X-Sync-Secret' => SYNC_SECRET,
],
]);
}, 10, 2);
Run this sync for at least 2 weeks before cutover. You want to catch edge cases — weird shortcodes, custom post types, ACF fields that don't map cleanly.

Phase 2: Building the Next.js Application
I'm not going to cover the full Next.js build process here — that deserves its own article (and we've got deep expertise in Next.js development). But there are migration-specific concerns that matter for zero downtime.
URL Parity Is Non-Negotiable
Every single URL that exists on your WordPress site must resolve to the same content on your Next.js site. Every. Single. One.
This means:
/blog/my-post-slug/must work (including the trailing slash if WordPress used one)/category/technology/must work or redirect/wp-content/uploads/2024/03/image.jpgmust redirect to your new image CDN/feed/must still return valid RSS/Atom- Pagination URLs like
/blog/page/2/must work
Build a URL audit script early:
# Export all WordPress URLs using WP-CLI
wp post list --post_type=post,page --post_status=publish \
--fields=ID,post_name,post_type,guid --format=csv > urls.csv
# Also grab redirects from any SEO plugin
wp db query "SELECT * FROM wp_redirection_items" --format=csv > redirects.csv
Then validate them against your Next.js build:
// validate-urls.mjs
import { readFileSync } from 'fs';
import { parse } from 'csv-parse/sync';
const urls = parse(readFileSync('./urls.csv'), { columns: true });
const NEXT_BASE = 'https://staging.yoursite.com';
for (const row of urls) {
const res = await fetch(`${NEXT_BASE}/${row.post_name}/`, {
redirect: 'manual'
});
if (res.status >= 400) {
console.error(`BROKEN: /${row.post_name}/ → ${res.status}`);
}
}
Performance Baseline
Before cutover, your Next.js site needs to be at least as fast as WordPress. Sounds obvious, but I've seen teams get so excited about the new stack that they forget to benchmark.
Capture Core Web Vitals from your WordPress site using CrUX data or Lighthouse CI, then match or beat them. If your WordPress site has LCP of 2.1s and your Next.js site is at 3.4s, you're not ready.
Phase 3: Parallel Deployment Setup
Now we get to the infrastructure that makes zero downtime possible.
The Parallel Run
Both systems run simultaneously, serving real traffic. But only one is "primary" at any given time.
For Next.js on Vercel (our most common setup), you'll deploy your Next.js app to a custom domain like next.yoursite.com or use Vercel's preview URL. Your WordPress site continues running at yoursite.com.
# If you're using Nginx as a reverse proxy
# This is the parallel run configuration
upstream wordpress {
server wordpress-origin.internal:80;
}
upstream nextjs {
server next-yoursite.vercel.app:443;
}
server {
listen 443 ssl;
server_name yoursite.com;
# During parallel run: mirror traffic to both,
# but serve responses from WordPress
location / {
mirror /mirror;
proxy_pass http://wordpress;
}
location = /mirror {
internal;
proxy_pass https://nextjs$request_uri;
}
}
This mirror configuration sends every request to both backends but only returns the WordPress response. You get real traffic hitting your Next.js app without users seeing it. Check your Next.js logs for errors, 404s, and slow responses.
Synthetic Monitoring
Set up monitoring that continuously validates both systems return equivalent content:
// canary-check.mjs — runs every 5 minutes via cron
const CRITICAL_URLS = [
'/',
'/blog/',
'/about/',
'/contact/',
'/blog/most-popular-post/',
];
for (const path of CRITICAL_URLS) {
const [wpRes, nextRes] = await Promise.all([
fetch(`https://yoursite.com${path}`),
fetch(`https://next.yoursite.com${path}`),
]);
if (wpRes.status !== nextRes.status) {
alert(`Status mismatch on ${path}: WP=${wpRes.status} Next=${nextRes.status}`);
}
// Compare title tags as a content parity check
const wpTitle = (await wpRes.text()).match(/<title>(.*?)<\/title>/)?.[1];
const nextTitle = (await nextRes.text()).match(/<title>(.*?)<\/title>/)?.[1];
if (wpTitle !== nextTitle) {
alert(`Title mismatch on ${path}`);
}
}
Run this for a minimum of 1 week with zero alerts before you proceed to cutover.
Phase 4: Blue-Green Deployment Configuration
Blue-green deployment means having two identical production environments — blue (current WordPress) and green (new Next.js) — and switching between them atomically.
Using Cloudflare Workers for Traffic Routing
This is our preferred approach because it gives you instant, global traffic switching with no DNS propagation delay.
// Cloudflare Worker for blue-green routing
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Read the active environment from KV store
const activeEnv = await env.CONFIG.get('active_environment') || 'blue';
// Optional: percentage-based canary routing
const canaryPercent = parseInt(await env.CONFIG.get('canary_percent') || '0');
const useGreen = activeEnv === 'green' ||
(canaryPercent > 0 && Math.random() * 100 < canaryPercent);
const origin = useGreen
? 'https://next-yoursite.vercel.app'
: 'https://wp-origin.yoursite.com';
const originUrl = new URL(url.pathname + url.search, origin);
const response = await fetch(originUrl, {
method: request.method,
headers: {
...Object.fromEntries(request.headers),
'Host': new URL(origin).hostname,
'X-Forwarded-Host': url.hostname,
},
body: request.method !== 'GET' ? request.body : undefined,
});
const newResponse = new Response(response.body, response);
newResponse.headers.set('X-Served-By', useGreen ? 'green-nextjs' : 'blue-wordpress');
return newResponse;
}
};
The beauty of this approach: switching from WordPress to Next.js is a single KV store write. No DNS changes. No propagation. Instant.
# Switch to green (Next.js)
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/{account_id}/storage/kv/namespaces/{namespace_id}/values/active_environment" \
-H "Authorization: Bearer ${CF_TOKEN}" \
--data "green"
# Rollback to blue (WordPress) if something goes wrong
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/{account_id}/storage/kv/namespaces/{namespace_id}/values/active_environment" \
-H "Authorization: Bearer ${CF_TOKEN}" \
--data "blue"
Canary Routing
Don't flip 100% of traffic at once. Start with a canary:
- 1% traffic to Next.js — Watch for errors for 1 hour
- 10% traffic — Monitor for 2 hours
- 50% traffic — Monitor for 4 hours
- 100% traffic — Monitor for 24 hours
- Decommission WordPress — After 1 week at 100%
Phase 5: DNS Switchover Strategy
If you can't use Cloudflare Workers (or a similar edge routing solution), you'll need to handle the switchover at the DNS level. This is trickier because of TTL propagation.
Pre-Cutover DNS Preparation
At least 48 hours before cutover:
- Lower your DNS TTL to 60 seconds (from the typical 3600 or 86400)
- Wait for the old TTL to expire
- Verify the low TTL is active:
dig yoursite.com +shortshould show TTL ~60
# Check current TTL
dig yoursite.com A +noall +answer
# Should show something like:
# yoursite.com. 60 IN A 76.76.21.21
The DNS Switch
With a 60-second TTL, updating your DNS A/CNAME record means global propagation in about 5-10 minutes (some resolvers ignore low TTLs, but most respect them in 2025).
# If moving to Vercel
# Update CNAME to point to cname.vercel-dns.com
# Or update A records to Vercel's IP addresses: 76.76.21.21
The Gotcha: SSL Certificate Timing
Here's something that bites people. When you switch DNS to Vercel (or any new host), the SSL certificate for your domain needs to be provisioned on the new host before the switch. Otherwise, you get a window where HTTPS doesn't work.
On Vercel, add your custom domain in the project settings before the DNS switch. Vercel will attempt to provision the cert via HTTP-01 or DNS-01 challenge. If you're using Cloudflare proxied DNS, you might need to temporarily disable the proxy (orange cloud → grey cloud) for cert provisioning to work.
Phase 6: The Cutover Checklist
This is the checklist we use on cutover day. Print it out. Check every box.
Pre-Cutover (T-24 hours)
- All content synced and verified in new CMS
- URL parity validation passing 100%
- SSL certificate provisioned on new host
- DNS TTL confirmed at 60 seconds
- Rollback procedure documented and tested
- All redirects from WordPress SEO plugin migrated to
next.config.jsor edge middleware -
robots.txtandsitemap.xmlgenerating correctly on Next.js - Analytics tracking verified on Next.js (GA4, GTM, etc.)
- Form submissions tested end-to-end
- RSS feed validated
- OpenGraph tags verified on key pages
- 404 page tested
Cutover (T-0)
- Notify stakeholders: "Migration starting"
- Set canary to 1% → monitor 30 min
- Increase to 10% → monitor 1 hour
- Check Google Search Console for crawl errors
- Increase to 50% → monitor 2 hours
- Increase to 100%
- Submit updated sitemap to Google Search Console
- Verify Googlebot can access all critical pages (use URL Inspection tool)
- Test all forms, e-commerce flows, auth flows
Post-Cutover (T+24 hours)
- Monitor Core Web Vitals in CrUX dashboard
- Check Google Search Console for coverage issues
- Verify all analytics data flowing correctly
- WordPress origin still running (keep for 2 weeks minimum)
- Run full-site crawl with Screaming Frog against new site
Phase 7: Post-Cutover Monitoring and Rollback
What to Monitor
Set up dashboards in your monitoring tool (we use a combination of Vercel Analytics, Datadog, and Google Search Console) tracking:
- Error rates: Any 5xx responses? Any uptick in 4xx?
- Response times: P50, P95, P99 latency
- Core Web Vitals: LCP, FID/INP, CLS
- Search Console: Crawl stats, coverage report, indexing status
- Business metrics: Conversion rate, bounce rate, pages/session
The Rollback Plan
Your rollback needs to be a single command. Not a 15-step process. One command.
With the Cloudflare Worker approach:
# One command rollback
wrangler kv:key put --namespace-id=$NS_ID "active_environment" "blue"
With DNS-based switching:
# Pre-scripted DNS rollback via Cloudflare API
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"content": "old-wordpress-ip-address"}'
Keep WordPress running for at least 2 weeks after cutover. Don't be a hero. The moment you shut down WordPress is the moment you discover a page you forgot to migrate.
Common Failure Modes and How to Prevent Them
After doing this dozens of times, here are the failures I see most often:
1. Forgotten WordPress plugins with frontend routes
That contact form plugin creates /wp-json/contact-form-7/ endpoints. That WooCommerce install has /my-account/ and /cart/. Map every plugin's URL footprint.
2. Hardcoded wp-content URLs in content
Images in your content reference /wp-content/uploads/. You need redirects or a rewrite rule pointing these to your new asset CDN.
// next.config.js
module.exports = {
async redirects() {
return [
{
source: '/wp-content/uploads/:path*',
destination: 'https://cdn.yoursite.com/uploads/:path*',
permanent: true,
},
];
},
};
3. Forgetting about XML sitemaps
Google Search Console is pointed at /sitemap.xml. Your Next.js app needs to generate one. Use next-sitemap or build it in your app's route handlers.
4. Authentication and session issues If your WordPress site has logged-in users, their cookies won't work on the new stack. Plan the user migration separately.
5. CDN cache poisoning during transition If Cloudflare is caching responses, you might serve stale WordPress pages after switching to Next.js. Purge the CDN cache immediately after switching.
# Purge entire Cloudflare cache after switching
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"purge_everything": true}'
If you're planning a migration and want experts to handle the heavy lifting, check out our pricing page or get in touch directly. We've done this enough times that our playbooks are battle-scarred in all the right ways.
For sites built with other frameworks, we also do Astro-based migrations which can be even faster for content-heavy sites that don't need much interactivity.
FAQ
How long does a WordPress to Next.js migration typically take? For a medium-complexity site (100-500 pages, custom post types, some dynamic functionality), plan for 8-12 weeks end-to-end. The content migration and parallel run phase takes 3-4 weeks alone. The actual cutover, if you've done the prep work, takes about 4 hours of active work. Don't let anyone tell you this can be done in a weekend.
Can I use WordPress as a headless CMS instead of migrating content? Absolutely, and for some teams this is the right call. You keep WordPress as your CMS, use the REST API or WPGraphQL to feed content to Next.js, and only migrate the frontend. This cuts the migration timeline significantly because you skip the content migration phase entirely. The tradeoff is you're still maintaining a WordPress install with all its update and security overhead.
What happens to my SEO rankings during a migration? With a proper zero-downtime migration, your rankings should remain stable. Google's John Mueller has confirmed that changing your CMS shouldn't affect rankings if the content, URLs, and technical SEO elements remain equivalent. The biggest risks are broken URLs (which cause 404s), changed internal linking structures, and degraded page speed. Our playbook specifically addresses all three.
How do I handle WordPress forms in Next.js? You have several options: use a form service like Formspree or Basin, build API routes in Next.js that handle submissions directly, or use your headless CMS's form features (Sanity doesn't have native forms, but Payload CMS does). For complex forms with conditional logic, we typically build custom API routes and use React Hook Form on the frontend.
Should I use Vercel, Netlify, or self-host for the Next.js deployment?
For most teams, Vercel is the path of least resistance for Next.js. It's built by the same team, and features like ISR, middleware, and image optimization work best there. Vercel's Pro plan at $20/user/month covers most production needs. If you have specific compliance requirements or need to stay on AWS, you can self-host with the standalone output mode. Netlify works too but has historically lagged behind on Next.js feature support.
What's the difference between blue-green deployment and canary deployment? Blue-green is binary: all traffic goes to either the old system (blue) or the new one (green). Canary deployment gradually shifts a percentage of traffic from old to new. In practice, we combine both. We set up blue-green infrastructure (two complete environments) but use canary-style percentage-based routing during the actual switchover. This gives you the safety of gradual rollout with the simplicity of having only two environments to manage.
How do I migrate WordPress redirects to Next.js?
Export your redirects from whatever WordPress plugin you're using (Redirection, Yoast, RankMath). Convert them to Next.js redirect format in next.config.js. For sites with hundreds of redirects, use middleware instead — it's more performant and can handle pattern matching. Be aware that next.config.js redirects have a practical limit of about 1,000 entries on Vercel before build times suffer.
Can I roll back to WordPress if something goes wrong after cutover? Yes, and this is non-negotiable. Keep your WordPress instance running for at least 2 weeks after cutover. With the Cloudflare Worker approach described in this article, rollback is a single API call that takes effect globally within seconds. With DNS-based switching, rollback takes 1-10 minutes depending on TTL propagation. Never decommission the old system until you're confident the new one is stable.