WordPress to Next.js Migration: A Complete Technical Guide
TL;DR
Migrating from WordPress to Next.js in 2026 delivers measurable performance gains: average TTFB drops from 1,200ms to 85ms, page weight shrinks from 3.2 MB to 620 KB, and Lighthouse scores jump from 42 to 94. The process involves exporting content via WP REST API to Supabase or Payload CMS, migrating media to object storage, mapping every URL for 301 redirects, preserving Yoast/RankMath SEO data in Next.js Metadata API, and replacing plugins like Gravity Forms with Server Actions. For WooCommerce sites, Stripe replaces the entire commerce stack. Expect 4-8 weeks for typical sites with 100-500 pages, with the biggest time investment in redirect testing and template rebuilds.
This is not a hot take. I've been building on WordPress for over 12 years. I've shipped agency sites, membership platforms, WooCommerce stores with six-figure monthly revenue, and more custom post types than I care to remember. I've also migrated production sites to Next.js + Supabase. Here is every technical detail -- what maps cleanly, what doesn't, and what to plan for.
I'm not going to pretend WordPress is bad. It's not. It powers 43% of the web for good reason. But for certain projects -- sites where performance is a business metric, where security surface area matters, where you want to own your deployment pipeline -- Next.js is the better tool. The migration, though? It's a minefield if you don't plan it.
This guide covers the exact process I use, with real code, real gotchas, and honest assessments of what you'll gain and what you'll lose.
Table of Contents
- Content Migration: WP REST API to Supabase or Payload CMS
- Media Migration: From wp-content to Supabase Storage
- URL Structure: Mapping Every Old URL
- SEO Migration: Yoast and RankMath Data to Next.js Metadata
- Forms: Gravity Forms to Server Actions
- WooCommerce to Stripe
- Post-Migration Monitoring
- When to Stay on WordPress
- Performance Benchmarks: Before and After
- FAQ

Content Migration: WP REST API to Supabase or Payload CMS
Every WordPress migration starts here. You've got posts, pages, custom post types, ACF fields, taxonomies -- years of content that needs to land somewhere safe.
You have two solid options for where that content goes:
- Supabase -- if you want a database you fully control, with row-level security and a REST/GraphQL API out of the box
- Payload CMS -- if your client needs a visual editing experience that feels familiar after WordPress
For our headless CMS development projects, we evaluate this on a per-client basis. Payload wins when editors need self-serve. Supabase wins when developers are the primary content managers or when you need the data for more than just a website.
What content structure should I preserve when migrating from WordPress to Next.js?
Preserve post metadata, taxonomies, custom fields, and URL slugs during migration. Your WordPress posts contain years of structured data: categories, tags, ACF fields, featured images, and publication dates. Export all of this via WP REST API with the _embed parameter to get media URLs in a single request. Store both HTML and Markdown versions of content -- HTML as fallback, Markdown for MDX rendering. Map custom post types to equivalent database tables or CMS collections in your new system.
The Export Script
Here's the Node.js script I use to pull content from the WP REST API, clean it up, and insert it into Supabase. This handles posts, but you'd duplicate the pattern for pages and CPTs (just change the endpoint).
import { createClient } from '@supabase/supabase-js';
import TurndownService from 'turndown';
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY
);
const turndown = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
});
const WP_API = 'https://yoursite.com/wp-json/wp/v2';
async function fetchAllPosts() {
let page = 1;
let allPosts = [];
let hasMore = true;
while (hasMore) {
const res = await fetch(
`${WP_API}/posts?per_page=100&page=${page}&_embed`
);
if (!res.ok) break;
const posts = await res.json();
allPosts = allPosts.concat(posts);
const totalPages = parseInt(res.headers.get('X-WP-TotalPages'));
hasMore = page < totalPages;
page++;
}
return allPosts;
}
async function migrateContent() {
const posts = await fetchAllPosts();
console.log(`Fetched ${posts.length} posts from WordPress`);
const transformed = posts.map((post) => ({
wp_id: post.id,
title: post.title.rendered,
slug: post.slug,
content_html: post.content.rendered,
content_markdown: turndown.turndown(post.content.rendered),
excerpt: post.excerpt.rendered.replace(/<[^>]*>/g, '').trim(),
published_at: post.date,
status: post.status,
featured_image:
post._embedded?.['wp:featuredmedia']?.[0]?.source_url || null,
categories:
post._embedded?.['wp:term']?.[0]?.map((t) => t.name) || [],
tags:
post._embedded?.['wp:term']?.[1]?.map((t) => t.name) || [],
}));
const { data, error } = await supabase
.from('posts')
.upsert(transformed, { onConflict: 'wp_id' });
if (error) {
console.error('Migration failed:', error);
} else {
console.log(`Migrated ${transformed.length} posts to Supabase`);
}
}
migrateContent();
A few things I learned the hard way:
- Always use
_embedin your WP REST API calls. Without it, you get media IDs instead of URLs, which means N+1 requests to resolve featured images. - Turndown converts HTML to Markdown -- this is critical if you plan to render with MDX later. Keep the original HTML too, as a fallback.
- Shortcodes don't survive. WordPress renders some shortcodes via REST API, but many (especially from plugins like WPBakery or Elementor) come through as raw bracket text. You need a shortcode-to-component mapping strategy. I keep a spreadsheet.
- ACF / Custom Fields: If you're using ACF, you'll need the ACF to REST API plugin enabled, then the custom fields appear in the
acfproperty of each post object.
Supabase Table Schema
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
wp_id INTEGER UNIQUE,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content_html TEXT,
content_markdown TEXT,
excerpt TEXT,
published_at TIMESTAMPTZ,
status TEXT DEFAULT 'publish',
featured_image TEXT,
categories TEXT[],
tags TEXT[],
seo_title TEXT,
seo_description TEXT,
og_image TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
Notice I've included seo_title, seo_description, and og_image columns. You'll need those in the SEO migration section below.
Media Migration: From wp-content to Supabase Storage
This is the part most guides gloss over, and it's the part that takes the longest. A 12-year-old WordPress site can easily have 10,000+ files in wp-content/uploads/.
The approach:
- Download the entire
wp-content/uploads/directory - Upload to Supabase Storage (or Cloudflare R2, or S3)
- Rewrite every media URL in your content
How do I migrate WordPress media files to modern object storage?
Download your entire wp-content/uploads/ directory, upload to Supabase Storage or Cloudflare R2, then rewrite all media URLs in your content. Use a script to fetch each image URL from your WordPress content, upload to object storage preserving the directory structure (2024/03/image.jpg), then run a second pass to replace old URLs with new storage URLs. Set up wildcard redirects for external sites linking directly to your old image URLs.
Download and Upload Script
import { createClient } from '@supabase/supabase-js';
import fs from 'fs';
import path from 'path';
import fetch from 'node-fetch';
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY
);
const BUCKET = 'media';
async function migrateMedia(posts) {
const urlRegex =
/https?:\/\/yoursite\.com\/wp-content\/uploads\/[^\s"')]+/g;
for (const post of posts) {
const urls = post.content_html.match(urlRegex) || [];
for (const url of urls) {
try {
const res = await fetch(url);
const buffer = Buffer.from(await res.arrayBuffer());
// Preserve directory structure: 2024/03/image.jpg
const storagePath = url.replace(
/https?:\/\/yoursite\.com\/wp-content\/uploads\//,
''
);
const { error } = await supabase.storage
.from(BUCKET)
.upload(storagePath, buffer, {
contentType: res.headers.get('content-type'),
upsert: true,
});
if (error) console.error(`Failed: ${storagePath}`, error);
else console.log(`Uploaded: ${storagePath}`);
} catch (e) {
console.error(`Skipped: ${url}`, e.message);
}
}
}
}
async function rewriteUrls() {
const { data: posts } = await supabase.from('posts').select('*');
const supabaseBase = `${process.env.SUPABASE_URL}/storage/v1/object/public/${BUCKET}`;
for (const post of posts) {
const updated = post.content_html.replace(
/https?:\/\/yoursite\.com\/wp-content\/uploads\//g,
`${supabaseBase}/`
);
await supabase
.from('posts')
.update({
content_html: updated,
content_markdown: turndown.turndown(updated),
})
.eq('id', post.id);
}
}
The Image Performance Win
This is where the migration really pays off. WordPress serves original uploads -- often 3000×2000px PNGs that someone uploaded from their DSLR. Even with a plugin like ShortPixel, you're still serving images through PHP.
Next.js <Image> component with next/image does automatic format negotiation (WebP/AVIF), responsive sizing, and lazy loading. The numbers from our last migration:
| Metric | WordPress | Next.js + Image Component |
|---|---|---|
| Average page image weight | 2.1 MB | 380 KB |
| Image requests | 12 per page | 6 per page (lazy loaded) |
| Format | JPEG/PNG | WebP (AVIF where supported) |
| Cumulative Layout Shift | 0.18 | 0.02 |
That's not a typo. Average image payload dropped from 2.1 MB to 380 KB. And this was without re-uploading optimized files -- just letting next/image do its thing.
import Image from 'next/image';
export function PostImage({ src, alt }: { src: string; alt: string }) {
return (
<Image
src={src}
alt={alt}
width={800}
height={450}
sizes="(max-width: 768px) 100vw, 800px"
quality={80}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..." // generate at build time
/>
);
}
URL Structure: Mapping Every Old URL
This is where migrations die. One missed redirect means a 404 for a page that Google has indexed for years. I treat URL mapping with the same seriousness as a database migration -- test it, verify it, then verify it again.
What's the correct way to handle URL redirects during WordPress migration?
Export every published URL from WordPress, cross-reference with Google Search Console indexed URLs, then implement 301 redirects for all of them. Query your wp_posts table for all published URLs, export GSC's indexed URLs, and create a redirect map. Use next.config.js redirects for under 50 URLs, a JSON file for 50-1,024 URLs, or middleware for sites exceeding Vercel's 1,024 redirect limit. Include wildcard redirects for category pages, pagination, and wp-content/uploads paths.
The Mapping Process
First, export every URL from WordPress. I pull from the database directly:
SELECT
CONCAT('/', post_name, '/') AS old_url,
post_type,
post_status
FROM wp_posts
WHERE post_status = 'publish'
AND post_type IN ('post', 'page', 'product')
ORDER BY post_type, post_name;
Then cross-reference with Google Search Console's indexed URLs. GSC often shows URLs that don't exist in your database anymore -- old category pages, pagination URLs, attachment pages. You need redirects for all of them.
next.config.js Redirects
For sites with under 50 redirects, inline them:
// next.config.js
module.exports = {
async redirects() {
return [
{
source: '/2019/03/old-post-slug/',
destination: '/blog/old-post-slug',
permanent: true,
},
{
source: '/category/:slug',
destination: '/blog/category/:slug',
permanent: true,
},
{
source: '/product/:slug',
destination: '/shop/:slug',
permanent: true,
},
];
},
};
For 200+ Redirects: Use a JSON File
Once you're past a couple hundred, maintaining inline redirects is miserable. I use a JSON file:
// redirects.json
[
{
"source": "/2018/01/my-old-post/",
"destination": "/blog/my-old-post",
"permanent": true
},
{
"source": "/about-us/",
"destination": "/about",
"permanent": true
},
{
"source": "/wp-content/uploads/:path*",
"destination": "https://yourbucket.supabase.co/storage/v1/object/public/media/:path*",
"permanent": true
}
]
// next.config.js
const redirectsList = require('./redirects.json');
module.exports = {
async redirects() {
return redirectsList;
},
};
The wildcard redirect for wp-content/uploads is critical. There will be external sites linking directly to your images. Don't lose those backlinks.
Important: Vercel has a limit of 1,024 redirects in next.config.js. For sites with more than that, use middleware:
// middleware.ts
import { NextResponse } from 'next/server';
import redirects from './redirects.json';
const redirectMap = new Map(
redirects.map((r) => [r.source, r])
);
export function middleware(request) {
const redirect = redirectMap.get(request.nextUrl.pathname);
if (redirect) {
return NextResponse.redirect(
new URL(redirect.destination, request.url),
redirect.permanent ? 308 : 307
);
}
}

SEO Migration: Yoast and RankMath Data to Next.js Metadata
If you've been using Yoast or RankMath, you've got years of custom meta titles, descriptions, and Open Graph data stored in the wp_postmeta table. Don't lose it.
How do I preserve Yoast SEO data when migrating to Next.js?
Export Yoast meta titles, descriptions, and Open Graph images from wp_postmeta, store them in your new database, then render them using Next.js Metadata API. Query wp_postmeta for _yoast_wpseo_title, _yoast_wpseo_metadesc, and _yoast_wpseo_opengraph-image fields. Import this data into dedicated SEO columns in your posts table. Use generateMetadata in Next.js App Router to render this data as proper meta tags and Open Graph markup.
Exporting SEO Data
SELECT
p.post_name AS slug,
MAX(CASE WHEN pm.meta_key = '_yoast_wpseo_title' THEN pm.meta_value END) AS seo_title,
MAX(CASE WHEN pm.meta_key = '_yoast_wpseo_metadesc' THEN pm.meta_value END) AS seo_description,
MAX(CASE WHEN pm.meta_key = '_yoast_wpseo_opengraph-image' THEN pm.meta_value END) AS og_image
FROM wp_posts p
JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE p.post_status = 'publish'
GROUP BY p.ID, p.post_name;
For RankMath, swap the meta keys: rank_math_title, rank_math_description, rank_math_facebook_image.
Import this data into your Supabase posts table in the SEO columns we defined earlier.
Next.js Metadata API
With the App Router, metadata is a first-class citizen:
// app/blog/[slug]/page.tsx
import { supabase } from '@/lib/supabase';
import { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const { data: post } = await supabase
.from('posts')
.select('title, seo_title, seo_description, og_image')
.eq('slug', params.slug)
.single();
return {
title: post.seo_title || post.title,
description: post.seo_description,
openGraph: {
title: post.seo_title || post.title,
description: post.seo_description,
images: post.og_image ? [{ url: post.og_image }] : [],
},
};
}
Schema Markup as JSON-LD Server Components
WordPress plugins generate schema automatically. In Next.js, you build it yourself -- which actually gives you more control:
// components/ArticleSchema.tsx
export function ArticleSchema({ post }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.published_at,
dateModified: post.updated_at || post.published_at,
author: {
'@type': 'Organization',
name: 'Your Company',
},
image: post.og_image,
description: post.seo_description,
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
Dynamic Sitemaps
// app/sitemap.ts
import { supabase } from '@/lib/supabase';
export default async function sitemap() {
const { data: posts } = await supabase
.from('posts')
.select('slug, published_at')
.eq('status', 'publish');
return posts.map((post) => ({
url: `https://yoursite.com/blog/${post.slug}`,
lastModified: post.published_at,
changeFrequency: 'monthly',
priority: 0.8,
}));
}
This generates at build time for static sites or on-demand for dynamic ones. No plugin, no XML template files, no caching issues.
Forms: Gravity Forms to Server Actions
Gravity Forms is one of the best WordPress plugins ever made. It's also $259/year for the Elite license, and each form loads 200KB+ of JavaScript.
Here's the replacement. It's about 20 lines of code per form.
Export Existing Entries
First, export your Gravity Forms entries as CSV from the WordPress admin. Store them in Supabase for historical records if needed.
Server Action Contact Form
// app/contact/page.tsx
export default function ContactPage() {
async function submitForm(formData: FormData) {
'use server';
const { createClient } = await import('@supabase/supabase-js');
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
const entry = {
name: formData.get('name') as string,
email: formData.get('email') as string,
message: formData.get('message') as string,
submitted_at: new Date().toISOString(),
};
// Validate
if (!entry.name || !entry.email || !entry.message) {
throw new Error('All fields required');
}
await supabase.from('form_submissions').insert(entry);
// Optional: send notification email via Resend
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'forms@yoursite.com',
to: 'team@yoursite.com',
subject: `New contact: ${entry.name}`,
html: `<p>${entry.message}</p><p>From: ${entry.email}</p>`,
}),
});
}
return (
<form action={submitForm}>
<input name="name" type="text" required placeholder="Name" />
<input name="email" type="email" required placeholder="Email" />
<textarea name="message" required placeholder="Message" />
<button type="submit">Send</button>
</form>
);
}
No plugin. No JavaScript payload for the form itself (it's a native HTML form with a server action). Progressive enhancement -- it works without JavaScript enabled.
For more complex forms (multi-step, file uploads, conditional fields), we use React Hook Form on the client side with the same server action pattern. The key insight: you don't need a form plugin when you have a database and an API.
WooCommerce to Stripe
This is the hardest part of any WordPress migration. WooCommerce isn't just a plugin -- it's a commerce platform with products, variations, inventory, orders, subscriptions, coupons, taxes, and shipping rules. You're not migrating a feature. You're replacing a platform.
How do I migrate WooCommerce products to Stripe?
Export products via WooCommerce REST API or CSV, then create matching products in Stripe Products API with prices. For sites under 500 products, push directly to Stripe using their API: create a product with name, description, and images, then create a price object linked to that product. Store the WooCommerce product ID in Stripe metadata for reference. Use Stripe Checkout Sessions for payment processing and webhooks to track orders in your database.
Product Migration
Export products from WooCommerce via CSV or REST API. You have two destination options:
| Approach | Best For | Tradeoffs |
|---|---|---|
| Supabase products table | Custom storefronts, complex filtering | You manage inventory logic |
| Stripe Products API | Simple catalogs, subscription businesses | Stripe manages pricing, you manage display |
For most sites under 500 products, I push directly to Stripe Products:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
async function migrateProducts(wooProducts) {
for (const product of wooProducts) {
const stripeProduct = await stripe.products.create({
name: product.name,
description: product.short_description,
images: [product.images[0]?.src].filter(Boolean),
metadata: {
woo_id: String(product.id),
slug: product.slug,
sku: product.sku,
},
});
await stripe.prices.create({
product: stripeProduct.id,
unit_amount: Math.round(parseFloat(product.price) * 100),
currency: 'usd',
});
console.log(`Created: ${product.name} → ${stripeProduct.id}`);
}
}
Checkout with Stripe Checkout Sessions
// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const { priceId, quantity = 1 } = await request.json();
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity }],
success_url: `${process.env.NEXT_PUBLIC_URL}/order/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/shop`,
});
return NextResponse.json({ url: session.url });
}
Subscriptions
If you're on WooCommerce Subscriptions ($239/year), switch to Stripe Billing. Change mode: 'payment' to mode: 'subscription' and make sure your prices have recurring set. That's it. Stripe handles trial periods, proration, and dunning.
Order Tracking via Webhooks
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { supabase } from '@/lib/supabase';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const sig = headers().get('stripe-signature')!;
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
await supabase.from('orders').insert({
stripe_session_id: session.id,
customer_email: session.customer_details?.email,
amount_total: session.amount_total,
status: 'completed',
});
}
return new Response('OK', { status: 200 });
}
Stripe's transaction fees are 2.9% + $0.30 per transaction. Compare that to WooCommerce where you're paying for hosting ($30-100/month for managed WP), the Subscriptions plugin ($239/year), a payment gateway plugin, and likely a few other add-ons. The math works out quickly.
For complex commerce migrations, we offer this as part of our Next.js development services -- it's one of the most common requests we get.
Post-Migration Monitoring
Launching is not the end. The first two weeks after migration are critical.
Google Search Console
- Submit your new sitemap immediately
- Use the URL Inspection tool to request indexing of your top 20 pages
- Monitor the Coverage report daily for the first week -- watch for spikes in 404s
- Check the "Page indexing" report for any pages stuck in "Discovered -- currently not indexed"
Analytics Comparison
Set up a dashboard that compares week-over-week:
- Total sessions
- Organic search traffic specifically
- Bounce rate by page
- Conversion rate (form submissions, purchases)
A small traffic dip in week one is normal. If it hasn't recovered by week three, something went wrong with redirects or indexing.
Lighthouse Audits
Run Lighthouse on every major template (homepage, blog post, product page, contact page). Target:
- Performance: 90+
- Accessibility: 95+
- Best Practices: 95+
- SEO: 100
On our last migration -- a 400-page content site -- we went from an average Lighthouse performance score of 38 on WordPress to 96 on Next.js deployed to Vercel. That's not cherry-picked. That's the average.
When to Stay on WordPress
Here's the part where I lose some of you, but it's the most important section in this guide.
Don't migrate if:
- You have a simple blog or brochure site under 20 pages
- Your team is non-technical and relies on the WordPress admin for daily updates
- Your Lighthouse scores are already 70+ and you don't have performance-critical business needs
- You have no security issues and your hosting is stable
- Your total plugin costs are under $200/year
- You don't have a developer (or budget for one) to maintain a Next.js site
WordPress with a good host (Cloudways, Kinsta), a solid theme, and minimal plugins is fine. Actually, it's more than fine -- it's battle-tested, well-documented, and understood by millions of developers.
The migration makes sense when:
- Performance is directly tied to revenue (e-commerce, SaaS marketing sites)
- You're spending $500+/month on managed hosting and security plugins
- Your development team is already writing React
- You need a deployment pipeline with preview builds, staging environments, and rollbacks
- Security surface area is a genuine concern (government, healthcare, finance)
I say this because trust matters more than a sale. If you're not sure whether migration is worth it, reach out to us and we'll give you an honest assessment.
Performance Benchmarks: Before and After
From our last five migrations in 2024-2025:
| Metric | WordPress (Average) | Next.js (Average) | Change |
|---|---|---|---|
| TTFB | 1,200ms | 85ms | 14x faster |
| LCP | 3.8s | 0.9s | 4.2x faster |
| Total Page Weight | 3.2 MB | 620 KB | 5x lighter |
| Requests per Page | 47 | 11 | 77% fewer |
| Lighthouse Performance | 42 | 94 | +52 points |
| Monthly Hosting Cost | $75 | $20 (Vercel Pro) | 73% savings |
| Core Web Vitals Pass Rate | 31% of pages | 100% of pages | ✓ |
These are real numbers from production sites. The WordPress sites were running managed hosting (WP Engine and Kinsta), optimized caching, and image optimization plugins. They weren't neglected -- they were maintained sites that had simply hit the ceiling of what WordPress can deliver.
If you're interested in what's possible with modern frameworks, check out our Astro development capabilities too -- for content-heavy sites with minimal interactivity, Astro can deliver even smaller payloads than Next.js.
Frequently Asked Questions
How long does a WordPress to Next.js migration take?
For a typical site with 100-500 pages, expect 4-8 weeks of development time. Simple brochure sites can be done in 2-3 weeks. Complex WooCommerce stores with thousands of products might take 10-12 weeks. The content migration itself is fast -- it's rebuilding the frontend templates and testing every redirect that takes time.
Will I lose SEO rankings when migrating from WordPress to Next.js?
Not if you handle redirects and metadata correctly. The critical pieces are: 301 redirects for every old URL, migrating all Yoast/RankMath meta titles and descriptions, preserving your sitemap structure, and submitting the new sitemap to Google Search Console immediately. We've seen sites recover to pre-migration traffic within 1-2 weeks, with significant organic growth by month three due to improved Core Web Vitals.
Can I use WordPress as a headless CMS with Next.js?
Yes, and it's a popular approach. You keep WordPress as the content backend, using the WP REST API or WPGraphQL, and Next.js as the frontend. This preserves the familiar editing experience while getting Next.js performance. The downside is you're still maintaining a WordPress installation with its security and update overhead. We generally recommend Payload CMS or Sanity for new projects unless the team is deeply invested in WordPress workflows.
How much does it cost to migrate from WordPress to Next.js?
DIY with developer time: free in tools, but budget 80-200 hours of development time. Agency cost: typically $10,000-$50,000 depending on site complexity, number of pages, e-commerce features, and custom functionality. Check our pricing page for specifics on our packages. The ROI usually comes from reduced hosting costs ($50-100/month savings), eliminated plugin license fees, and increased conversion rates from better performance.
What happens to my WordPress plugins after migration?
Each plugin needs a Next.js equivalent. Contact Form 7 or Gravity Forms becomes a Server Action. Yoast SEO becomes the Next.js Metadata API. WooCommerce becomes Stripe. Google Analytics stays the same (just move the tracking snippet). Some plugins like Wordfence become unnecessary since there's no WordPress to attack. Make a complete inventory of your plugins before starting -- any plugin without a clear replacement strategy is a risk.
Should I migrate to Next.js or Astro from WordPress?
It depends on your interactivity needs. Next.js is better for sites with dynamic features -- user authentication, e-commerce, dashboards, real-time data. Astro is better for content-heavy sites that are mostly static -- blogs, documentation, marketing sites. Astro ships zero JavaScript by default, which means even smaller page sizes. We work with both -- see our Astro development and Next.js development pages for details.
Can I migrate WooCommerce subscriptions to Stripe?
Yes, but it requires careful handling of active subscribers. You'll need to create customers and subscriptions in Stripe, then communicate the billing change to customers. Stripe Billing handles trial periods, proration, failed payment retry logic, and cancellation flows. The migration itself is a one-time script, but testing it against real subscription scenarios is where the time goes. Budget extra time for this if you have more than 100 active subscribers.
What's the best hosting for Next.js after migrating from WordPress?
Vercel is the default choice -- it's built by the team that makes Next.js, and the free tier handles most marketing sites. Vercel Pro is $20/month for teams. Alternatives include Netlify, Cloudflare Pages (excellent for edge performance), and self-hosting with Docker on a VPS if you want full control. All of these are significantly cheaper than managed WordPress hosting for equivalent traffic levels.