I've migrated over a dozen WordPress sites to Next.js in the past three years. Some went smoothly. Some made me question my career choices at 2 AM on a Tuesday. The difference between those two outcomes almost always came down to planning — specifically, understanding what WordPress was actually doing for the site before ripping it out.

This guide is everything I wish someone had handed me before my first migration. We'll cover the full journey: evaluating whether you should even migrate, choosing your headless CMS, moving content, rebuilding templates, handling SEO without losing rankings, and deploying on Vercel with a setup that won't fall over under traffic spikes.

Let's get into it.

WordPress to Next.js Migration on Vercel: A 2026 Guide

Table of Contents

Why Migrate from WordPress to Next.js in 2026?

Let's be honest — WordPress still powers roughly 40% of the web in 2026. It's not going anywhere. But the reasons to leave have gotten more compelling:

Performance ceiling. Even with aggressive caching plugins (WP Rocket, W3 Total Cache), most WordPress sites hit a wall around 70-80 on Lighthouse performance scores. Plugin bloat, render-blocking PHP, and database queries on every page load create overhead that no amount of optimization can fully eliminate.

Security surface area. WordPress had 149 documented vulnerabilities in 2025 across core and popular plugins. Every plugin is an attack vector. Every theme update is a potential break. If you're running WooCommerce, the surface area doubles.

Developer experience. If your team knows React, building in PHP templates feels like writing with your non-dominant hand. Next.js 15's App Router, Server Components, and built-in caching give you a modern development workflow that WordPress can't match.

Cost at scale. Managed WordPress hosting (WP Engine, Kinsta) runs $30-$300/month for decent performance. Vercel's Pro plan at $20/user/month with edge functions and automatic scaling often costs less while performing better.

That said — don't migrate just because it's trendy. If your site is a simple blog with 50 posts and your client updates it weekly via the WordPress admin, a migration might create more problems than it solves. The best candidates for migration are:

  • Sites with 500+ pages that need better performance
  • Teams that want component-based development
  • Sites where plugin conflicts are causing maintenance nightmares
  • E-commerce sites hitting WooCommerce performance limits
  • Marketing sites that need A/B testing and personalization at the edge

Pre-Migration Audit: What WordPress Is Actually Doing

This is where most migrations go wrong. People think they're migrating a blog, but WordPress is actually handling contact forms, redirects, image optimization, search, comments, authentication, cron jobs, and fifteen other things buried in plugin configurations.

Before writing a single line of Next.js code, document everything:

Plugin Inventory

Export your plugin list and categorize each one:

wp plugin list --status=active --format=csv > active-plugins.csv

For each plugin, answer: What does this do, and what replaces it in the Next.js ecosystem?

WordPress Plugin Function Next.js Replacement
Yoast SEO Meta tags, sitemaps, schema next-seo + custom sitemap.xml route
WP Rocket Caching, minification Vercel Edge Cache + Next.js built-in
Contact Form 7 Form handling React Hook Form + API route or Formspree
Wordfence Security Not needed (no admin surface)
WPML Multilingual next-intl or built-in i18n routing
WooCommerce E-commerce Shopify Storefront API or Saleor
Advanced Custom Fields Custom content models Your headless CMS's content modeling
Redirection URL redirects next.config.js redirects or Vercel config
WP Cron Scheduled tasks Vercel Cron Jobs or separate service
Imagify Image optimization next/image with Vercel Image Optimization

Content Inventory

Count and categorize your content:

SELECT post_type, post_status, COUNT(*) 
FROM wp_posts 
GROUP BY post_type, post_status;

Don't forget: custom post types, taxonomy terms, user profiles, menu structures, widget configurations, and option values. That "simple" WordPress site probably has more content types than you think.

URL Mapping

Export every URL. Every single one. Use Screaming Frog or a simple sitemap crawl:

curl -s https://yoursite.com/sitemap_index.xml | \
grep -oP '<loc>\K[^<]+' | \
xargs -I {} curl -s {} | \
grep -oP '<loc>\K[^<]+' > all-urls.txt

This file is gold. You'll use it for redirect mapping, SEO preservation, and QA testing after migration.

WordPress to Next.js Migration on Vercel: A 2026 Guide - architecture

Choosing Your Headless CMS Backend

You need somewhere to store and manage content. The three most common paths in 2026:

Option 1: WordPress as a Headless CMS

Yes, you can keep WordPress as the backend and use Next.js as the frontend. WPGraphQL (now at v2.1) makes this surprisingly viable. Your editors keep the familiar admin interface. You get a modern frontend.

// lib/wordpress.js
const API_URL = process.env.WORDPRESS_GRAPHQL_URL;

export async function getPostBySlug(slug) {
  const res = await fetch(API_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query: `
        query PostBySlug($slug: ID!) {
          post(id: $slug, idType: SLUG) {
            title
            content
            date
            featuredImage {
              node {
                sourceUrl
                altText
              }
            }
            seo {
              title
              metaDesc
              opengraphImage {
                sourceUrl
              }
            }
          }
        }
      `,
      variables: { slug },
    }),
    next: { revalidate: 60 },
  });
  const json = await res.json();
  return json.data.post;
}

The downside? You still maintain a WordPress installation. Security updates, PHP version management, database backups — it all stays on your plate. And you're still paying for WordPress hosting.

Option 2: Purpose-Built Headless CMS

This is what I recommend for most migrations. Move your content to a CMS that was built from the ground up for API-first delivery.

CMS Pricing (2026) Best For Content Modeling API Type
Sanity Free tier, $15/user/mo Pro Complex content, real-time collaboration Excellent, code-defined GROQ + GraphQL
Contentful Free tier, $300/mo Team Enterprise, large teams Good, UI-defined REST + GraphQL
Storyblok Free tier, €106/mo Business Visual editing, components Great, visual REST + GraphQL
Strapi v5 Free (self-hosted), Cloud from $29/mo Full control, open source Flexible, UI-defined REST + GraphQL
Payload CMS 3.0 Free (self-hosted) Developers who want code-first Excellent, code-defined REST + GraphQL

If your team at Social Animal is handling the migration, we typically recommend Sanity or Payload for headless CMS development — they give developers the most control over content modeling while keeping editors happy.

Option 3: Markdown/MDX in the Repository

For developer blogs and documentation sites, storing content as MDX files in your Git repo is the simplest approach. No CMS to manage, no API calls, content versioned alongside code. But this only works if your content editors are comfortable with Git workflows.

Content Migration Strategy

Exporting from WordPress

The built-in WordPress export (Tools → Export) gives you an XML file. It's a start, but it's messy. For structured migration, I use a custom WP-CLI script:

// export-content.php
<?php
$posts = get_posts([
    'post_type' => ['post', 'page', 'your_custom_type'],
    'posts_per_page' => -1,
    'post_status' => 'publish',
]);

$export = [];
foreach ($posts as $post) {
    $export[] = [
        'id' => $post->ID,
        'title' => $post->post_title,
        'slug' => $post->post_name,
        'content' => apply_filters('the_content', $post->post_content),
        'excerpt' => $post->post_excerpt,
        'date' => $post->post_date,
        'modified' => $post->post_modified,
        'author' => get_the_author_meta('display_name', $post->post_author),
        'categories' => wp_get_post_categories($post->ID, ['fields' => 'names']),
        'tags' => wp_get_post_tags($post->ID, ['fields' => 'names']),
        'featured_image' => get_the_post_thumbnail_url($post->ID, 'full'),
        'acf' => function_exists('get_fields') ? get_fields($post->ID) : [],
        'yoast' => [
            'title' => get_post_meta($post->ID, '_yoast_wpseo_title', true),
            'description' => get_post_meta($post->ID, '_yoast_wpseo_metadesc', true),
        ],
    ];
}

file_put_contents('export.json', json_encode($export, JSON_PRETTY_PRINT));

Transforming Content

WordPress content is stored as HTML with Gutenberg block markup. You'll need to decide: keep the HTML and render it, or convert to your CMS's structured format?

For Sanity, I use @sanity/block-tools to convert HTML to Portable Text. For Contentful, their migration CLI handles rich text conversion. Either way, budget time for content cleanup — WordPress content is full of [shortcodes], inline styles, and broken HTML that needs to be cleaned up.

// migrate-to-sanity.js
import { htmlToBlocks } from '@sanity/block-tools';
import { JSDOM } from 'jsdom';
import { Schema } from '@sanity/schema';

const schema = Schema.compile({ /* your schema */ });
const blockContentType = schema.get('post')
  .fields.find(f => f.name === 'body').type;

function convertPost(wpPost) {
  return {
    _type: 'post',
    title: wpPost.title,
    slug: { current: wpPost.slug },
    publishedAt: wpPost.date,
    body: htmlToBlocks(
      wpPost.content,
      blockContentType,
      { parseHtml: (html) => new JSDOM(html).window.document }
    ),
  };
}

Image Migration

Don't skip this. Download every image from wp-content/uploads, re-upload to your CMS or a CDN (Cloudinary, Vercel Blob Storage, S3), and update all content references. I typically write a script that:

  1. Crawls the export JSON for image URLs
  2. Downloads each image
  3. Uploads to the new storage
  4. Creates a URL mapping file
  5. Runs find-and-replace across all content

Rebuilding Your Frontend in Next.js 15

Next.js 15 (stable since late 2024, with 15.2 current in 2026) uses the App Router by default. Here's the structure I use for content-heavy sites:

app/
├── layout.tsx          # Root layout with fonts, analytics
├── page.tsx            # Homepage
├── blog/
│   ├── page.tsx        # Blog listing with pagination
│   └── [slug]/
│       └── page.tsx    # Individual blog posts
├── [slug]/
│   └── page.tsx        # Generic pages
├── sitemap.ts          # Dynamic sitemap generation
├── robots.ts           # robots.txt
└── not-found.tsx       # Custom 404

Static Generation with ISR

For most content pages, Incremental Static Regeneration is the sweet spot — static performance with dynamic freshness:

// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPostSlugs } from '@/lib/cms';
import { notFound } from 'next/navigation';

export async function generateStaticParams() {
  const slugs = await getAllPostSlugs();
  return slugs.map((slug) => ({ slug }));
}

export async function generateMetadata({ params }) {
  const post = await getPostBySlug(params.slug);
  if (!post) return {};
  return {
    title: post.seo?.title || post.title,
    description: post.seo?.description || post.excerpt,
    openGraph: {
      images: [post.featuredImage?.url],
    },
  };
}

export const revalidate = 3600; // Revalidate every hour

export default async function BlogPost({ params }) {
  const post = await getPostBySlug(params.slug);
  if (!post) notFound();

  return (
    <article className="prose lg:prose-xl">
      <h1>{post.title}</h1>
      <time dateTime={post.date}>{formatDate(post.date)}</time>
      <PostBody content={post.body} />
    </article>
  );
}

For sites needing real-time updates (news, live content), use on-demand revalidation via webhooks from your CMS. Most headless CMS platforms support webhook triggers on content publish.

If you're looking at Next.js development and want to understand the rendering trade-offs more deeply, we've written about that separately.

URL Structure and SEO Preservation

This is non-negotiable. If you lose your URL structure, you lose your search rankings. Period.

Redirect Map

WordPress uses URL patterns like /2024/03/post-slug/ or /category/term/. Your Next.js site probably uses /blog/post-slug. Create a redirect map:

// next.config.js
module.exports = {
  async redirects() {
    return [
      // Date-based URLs to clean slugs
      {
        source: '/:year(\\d{4})/:month(\\d{2})/:slug',
        destination: '/blog/:slug',
        permanent: true,
      },
      // Category archives
      {
        source: '/category/:slug',
        destination: '/blog?category=:slug',
        permanent: true,
      },
      // Pagination
      {
        source: '/page/:num',
        destination: '/blog?page=:num',
        permanent: true,
      },
      // Feed URLs
      {
        source: '/feed',
        destination: '/rss.xml',
        permanent: true,
      },
    ];
  },
};

For large sites (1000+ redirects), use Vercel's vercel.json configuration or middleware instead — next.config.js redirects have a soft limit of around 1024 entries before build times start suffering.

Structured Data

WordPress plugins like Yoast auto-generate JSON-LD. You need to replicate this:

// components/structured-data.tsx
export function ArticleSchema({ post }) {
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    datePublished: post.date,
    dateModified: post.modified,
    author: {
      '@type': 'Person',
      name: post.author,
    },
    image: post.featuredImage?.url,
    publisher: {
      '@type': 'Organization',
      name: 'Your Site Name',
      logo: { '@type': 'ImageObject', url: '/logo.png' },
    },
  };

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  );
}

XML Sitemap

Next.js 15 makes dynamic sitemaps straightforward:

// app/sitemap.ts
import { getAllPosts, getAllPages } from '@/lib/cms';

export default async function sitemap() {
  const posts = await getAllPosts();
  const pages = await getAllPages();

  return [
    { url: 'https://yoursite.com', lastModified: new Date() },
    ...pages.map((page) => ({
      url: `https://yoursite.com/${page.slug}`,
      lastModified: page.modified,
    })),
    ...posts.map((post) => ({
      url: `https://yoursite.com/blog/${post.slug}`,
      lastModified: post.modified,
    })),
  ];
}

Deploying on Vercel: Configuration That Actually Works

Project Setup

npx create-next-app@latest my-migrated-site --typescript --tailwind --app
cd my-migrated-site
vercel link

Environment Variables

Set these in Vercel's dashboard, not in .env files committed to Git:

CMS_API_URL=https://your-cms-api-endpoint
CMS_API_TOKEN=your-read-only-token
REVALIDATION_SECRET=a-random-string-for-webhook-auth
SITE_URL=https://yoursite.com

Vercel Configuration

// vercel.json
{
  "crons": [
    {
      "path": "/api/revalidate-sitemap",
      "schedule": "0 */6 * * *"
    }
  ],
  "headers": [
    {
      "source": "/fonts/(.*)",
      "headers": [
        { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
      ]
    }
  ]
}

On-Demand Revalidation Webhook

// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidation-secret');
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
  }

  const body = await request.json();
  const { slug, type } = body;

  if (type === 'post') {
    revalidatePath(`/blog/${slug}`);
    revalidatePath('/blog'); // Revalidate listing too
  } else {
    revalidatePath(`/${slug}`);
  }

  return NextResponse.json({ revalidated: true });
}

Point your CMS webhook at https://yoursite.com/api/revalidate with the secret header. Now content updates appear within seconds without full rebuilds.

Performance Benchmarks: Before and After

These are real numbers from migrations we've completed at Social Animal in 2025-2026:

Metric WordPress (WP Engine) Next.js (Vercel) Improvement
Lighthouse Performance 62-78 95-100 +30-40%
Largest Contentful Paint 2.8-4.2s 0.8-1.4s 60-70% faster
Time to First Byte 800ms-1.5s 50-120ms 90%+ faster
Cumulative Layout Shift 0.12-0.25 0.01-0.05 ~80% reduction
Monthly hosting cost $115/mo avg $20-40/mo 60-80% savings
Build time (500 pages) N/A (dynamic) 45-90 seconds N/A
Pages/second (ISR) 15-30 req/s 10,000+ from edge Orders of magnitude

The TTFB improvement alone is worth the migration. WordPress generates every page through PHP and MySQL. Vercel serves pre-rendered pages from edge nodes in 300+ locations worldwide.

Common Migration Pitfalls

Pitfall 1: Forgetting WordPress RSS feeds. If people subscribe to your feed, redirect /feed/ and /rss/ to a new RSS endpoint. Next.js doesn't generate feeds by default — you need a custom route.

Pitfall 2: Missing WordPress shortcodes. Content exported from WordPress is riddled with [gallery], [embed], and plugin-specific shortcodes. If you don't parse and convert these, they render as plain text. Write transformers for each shortcode type your content uses.

Pitfall 3: Ignoring WordPress comment data. If you have valuable comment threads, migrate them to a service like Disqus or build a custom comment system. Most people just drop comments, but check with stakeholders first.

Pitfall 4: Not testing internal links. WordPress content is full of internal links using absolute URLs (https://yoursite.com/old-path/). These need to be updated to relative paths or your new URL structure. A simple regex find-and-replace during migration handles most cases.

Pitfall 5: Forgetting wp-content/uploads references. Even after migrating images, old content may reference /wp-content/uploads/2024/03/image.jpg paths. Set up a catch-all redirect or proxy these to your new image CDN.

If this feels overwhelming, that's honestly normal. A proper migration takes 4-12 weeks depending on site complexity. Check out our pricing or reach out directly if you want experienced hands on the project.

FAQ

How long does a WordPress to Next.js migration take? For a site with 100-500 pages, expect 4-8 weeks with a dedicated developer. Larger sites with custom post types, e-commerce, or multilingual content can take 8-16 weeks. The content migration itself is usually 20% of the work — the other 80% is rebuilding templates, handling edge cases, and QA testing every URL.

Will I lose my Google rankings during migration? Not if you handle redirects properly. Implement 301 redirects for every URL that changes, preserve your meta titles and descriptions, submit your new sitemap to Google Search Console, and use the Change of Address tool if your domain changes. Expect a small ranking fluctuation for 2-4 weeks, then recovery or improvement as Google recognizes better Core Web Vitals.

Can I keep using WordPress as my CMS with Next.js? Absolutely. This is called "headless WordPress" and it's a popular approach. Use WPGraphQL to expose your content as an API, then consume it from Next.js. Your editors keep the WordPress admin they know. The main downside is you still maintain a WordPress installation — security updates, hosting, the whole stack.

How much does it cost to migrate from WordPress to Next.js? DIY with one developer: 100-300 hours of work. Agency migration: typically $15,000-$75,000 depending on complexity. Ongoing hosting costs usually decrease — Vercel Pro at $20/user/month versus managed WordPress hosting at $50-$300/month. The ROI comes from reduced hosting costs, better performance (which improves conversion rates), and lower maintenance overhead.

Should I use the Pages Router or App Router in Next.js 15? App Router, full stop. In 2026, the App Router is the stable default with Server Components, streaming, and parallel routes. The Pages Router still works and isn't deprecated, but new features and optimizations are App Router-first. Every migration we do at Social Animal uses the App Router exclusively. Check our Next.js development capabilities for more on our approach.

What about forms and dynamic functionality? Next.js API routes (or Server Actions in the App Router) handle form submissions, search, authentication, and any server-side logic. For contact forms, you can use Server Actions with a service like Resend for email delivery. For search, consider Algolia or Meilisearch. For authentication, NextAuth.js (now Auth.js v5) covers most use cases.

Is Vercel the only option for hosting Next.js? No. You can deploy Next.js on Netlify, AWS Amplify, Cloudflare Pages, or self-host with Node.js. However, Vercel is built by the Next.js team, and the integration shows — ISR, edge middleware, image optimization, and analytics all work best on Vercel. The DX gap between Vercel and alternatives has narrowed in 2026, but Vercel remains the path of least resistance.

What if I need to migrate a WooCommerce store? This is a bigger project. Most teams migrate the storefront to Next.js while moving the commerce backend to Shopify (using Storefront API), Medusa.js, or Saleor. Shopify's Hydrogen framework is another option, but if you want full control over the frontend, Next.js with Shopify's API is the most proven path. Expect the e-commerce migration to add 4-8 weeks to your timeline.