Technical Guide

Migrating WordPress to Next.js on Vercel: The Complete Guide

$0-upfront DIY vs. done-for-you — a 15-step walkthrough of exporting WordPress content, preserving your SEO baseline, and rebuilding on Next.js and Vercel’s edge network.

ULTRA Design AgencyAugust 18, 2026~14 min read

The Hidden Cost of WordPress Speed Bloat

Every WordPress site starts fast. Then a page builder gets installed, then an SEO plugin, then a form plugin, a caching plugin to compensate for the first three, and a security plugin to watch all of it. Two years later the homepage is shipping 3–5MB of CSS and JavaScript before a visitor sees anything, and every core update carries the risk of breaking a plugin chain nobody fully remembers installing.

That bloat has a direct cost: slower Largest Contentful Paint hurts both conversion rate and Google’s Core Web Vitals ranking signal. Fixing it inside WordPress means auditing dozens of plugins one at a time. Fixing it by leaving WordPress’s rendering model entirely — static or server-rendered pages shipped from Next.js on Vercel’s edge network — removes the problem at the root instead of managing it.

This guide covers the real migration path: exporting your content, preserving the URL structure and redirects your SEO already depends on, and rebuilding the templates in Next.js’s App Router. It also covers where a plugin-based “static export” falls short of an actual rebuild, and where doing this yourself costs real engineering hours.

Simply Static vs. DIY Next.js vs. Done-For-You

Before picking a path, it helps to see the three options side by side. “Static export” plugins and a full Next.js rebuild solve different problems, even though both produce fast-loading HTML.

FactorSimply Static PluginManual DIY Next.jsULTRA Redesigned
Upfront cost$0–$60/yr$0 (your time)$0 upfront
Typical timelineA few hours40+ engineering hours~14 days, hands-off
What you getA frozen HTML snapshot of your existing WP markupA real, custom-built Next.js applicationA real, custom-built Next.js application
Core Web Vitals ceilingImproved, but capped by WP’s original bloated markupEffectively unlimited — you control every byte shippedEffectively unlimited
Forms, search, WooCommerce cartsBreak — these need a live PHP backend the export doesn’t haveFully supported — you rebuild them as componentsFully rebuilt for you
301 redirects & permalinksStill your responsibility to maintain in .htaccessYou write and test the redirect map yourselfMapped and preserved as part of the build
Ongoing maintenanceYou — WordPress core still needs updates to regenerate the exportYou — hosting, dependency updates, monitoringULTRA — hosting, updates, monitoring included

A static-export plugin is a legitimate short-term band-aid — it can meaningfully improve load time on a content-only brochure site with no forms or dynamic features. It is not a substitute for a rebuild if your site has lead forms, search, e-commerce, or membership content, because none of that survives being frozen into static HTML.

The 15-Step Migration Path

The steps below are the same ones we run internally on every client migration. If you’re doing this yourself, budget real time for steps 9–13 — that’s where a template swap turns into a genuine software project.

STEP 1 — Audit Your Current Site

Before touching any code, run your current site through PageSpeed Insights and record the baseline: LCP, CLS, and the total number of installed plugins. Export a full list of every published URL — posts, pages, categories, tags, and any custom post types. This list becomes your migration checklist and your redirect map’s source of truth.

STEP 2 — Export Content via the WordPress REST API

WordPress ships a REST API by default at /wp-json/wp/v2/. You can pull every post, page, and media reference without a plugin:

terminal
curl "https://your-site.com/wp-json/wp/v2/posts?per_page=100&page=1" curl "https://your-site.com/wp-json/wp/v2/pages?per_page=100" curl "https://your-site.com/wp-json/wp/v2/media?per_page=100"

The response is paginated — check the X-WP-TotalPages response header and loop until you’ve pulled every page. Save the raw JSON; you’ll transform it into your new content source in Step 9.

STEP 3 — Or Export via WPGraphQL

If your content model is more complex — custom fields via Advanced Custom Fields, relationships between post types — installing the free WPGraphQL plugin gives you a typed GraphQL schema instead of wrangling REST pagination by hand. It’s worth the plugin install for this one export step even on a site you’re about to retire, since it makes structured field data far easier to pull cleanly.

STEP 4 — Map Your Complete URL Structure

Go through your permalink settings and document the exact URL pattern for every content type —/blog/%postname%/, /services/%category%/%postname%/, and so on. Every one of these needs an equivalent route in the new Next.js app, or a redirect to a new location. This is the single most common source of an SEO ranking drop during a migration, and it’s entirely avoidable with a spreadsheet and twenty minutes.

STEP 5 — Document Every Existing Redirect Rule

Pull your current .htaccess file and any redirects configured through an SEO plugin (Yoast, Rank Math). Every 301 you’ve accumulated over the site’s life represents link equity — old backlinks, old bookmarks, old social shares — that breaks if it isn’t carried forward.

STEP 6 — Choose Your Rendering Strategy

Next.js’s App Router gives you three real choices per route: Static Generation (built at deploy time — fastest, best for content that doesn’t change per-request), Server Rendering (built per request — needed for personalized or frequently-changing data), and Incremental Static Regeneration (static, but automatically rebuilt on a schedule or on demand). For a typical WordPress content site — blog posts, service pages, location pages — static generation with a revalidation window is almost always the right default.

STEP 7 — Why a React SPA Alone Isn’t Enough

A common shortcut is rebuilding in plain React with client-side rendering only — a single-page app that fetches content in the browser. The problem is indexability and speed: a pure client-side SPA ships an empty HTML shell and renders content only after JavaScript executes, which both delays what a visitor sees and makes search engines work harder to index the page correctly. Next.js solves this by rendering full HTML on the server or at build time — the same JavaScript framework, but the content is present in the initial response instead of appearing after a client-side fetch.

STEP 8 — Scaffold the Next.js Project

terminal
npx create-next-app@latest my-migrated-site --typescript --app --eslint cd my-migrated-site git init git add -A && git commit -m "Initial Next.js scaffold"

STEP 9 — Build the Content-Fetching Layer

Wrap the REST API calls from Step 2 in a typed helper so every page component fetches content the same way:

lib/wp.ts
const WP_API_URL = process.env.WP_API_URL; export interface WPPost { id: number; slug: string; title: { rendered: string }; content: { rendered: string }; excerpt: { rendered: string }; date: string; } export async function getAllPosts(): Promise<WPPost[]> { const res = await fetch(`${WP_API_URL}/wp/v2/posts?per_page=100`, { next: { revalidate: 3600 }, // ISR: rebuild at most once per hour }); if (!res.ok) throw new Error(`WP API error: ${res.status}`); return res.json(); } export async function getPostBySlug(slug: string): Promise<WPPost | null> { const res = await fetch(`${WP_API_URL}/wp/v2/posts?slug=${slug}`, { next: { revalidate: 3600 }, }); const posts: WPPost[] = await res.json(); return posts[0] ?? null; }

STEP 10 — Rebuild Your Layout as a Server Component

app/layout.tsx
import type { Metadata } from "next"; export const metadata: Metadata = { title: { default: "Your Site", template: "%s | Your Site" }, description: "Migrated from WordPress to Next.js.", }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ); }

STEP 11 — Rebuild Each Post Template

app/blog/[slug]/page.tsx
import { notFound } from "next/navigation"; import { getPostBySlug, getAllPosts } from "@/lib/wp"; export async function generateStaticParams() { const posts = await getAllPosts(); return posts.map((p) => ({ slug: p.slug })); } export default async function BlogPost({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const post = await getPostBySlug(slug); if (!post) notFound(); return ( <article> <h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} /> <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} /> </article> ); }

This is the step that takes real time on a large site: every custom page builder layout, every shortcode, every widget area has to become a real component instead of WordPress-generated markup. A 20-page brochure site might take a weekend. A 200-page site with custom post types and ACF fields is the 40-hour-plus project referenced in the comparison table above.

One note on that example above: WordPress’s REST API returns title and content as pre-rendered HTML strings, so rendering them requires dangerouslySetInnerHTML. That’s standard for a migration where you control the WP install and its authors — if your site accepts untrusted user-submitted content, sanitize it with a library like DOMPurify before rendering.

STEP 12 — Rebuild Dynamic Forms & Interactive Elements

Contact forms, search, and anything else that used a WordPress plugin’s PHP backend needs a new home. In Next.js, that’s typically a Client Component for the interactive form UI, submitting to a Route Handler or an external form-processing service. This is also where request caching for anything user-specific (search results, personalized content) needs to be explicitly set to bypass the default caching behavior — a subtlety that trips up most first-time App Router migrations.

STEP 13 — Preserve Your SEO Metadata

Every page’s title tag, meta description, and Open Graph data needs to carry over exactly, along with any structured data (JSON-LD) your SEO plugin was generating. Next.js’s generateMetadata function handles this per-route:

app/blog/[slug]/page.tsx
export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; const post = await getPostBySlug(slug); if (!post) return {}; return { title: post.title.rendered, description: post.excerpt.rendered.replace(/<[^>]+>/g, "").slice(0, 160), }; }

STEP 14 — Implement Your 301 Redirect Map

Take the redirect list from Step 5 and the URL map from Step 4, and encode every rule where the new route structure differs from the old one:

next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = { async redirects() { return [ { source: "/old-blog-path/:slug", destination: "/blog/:slug", permanent: true }, { source: "/category/:cat", destination: "/blog/category/:cat", permanent: true }, ]; }, }; export default nextConfig;

Test every redirect after deploying — a silently broken 301 is functionally identical to a 404 as far as Google and your existing backlinks are concerned.

STEP 15 — Deploy to Vercel & Cut Over DNS

terminal
git remote add origin https://github.com/your-username/your-repo.git git push -u origin main npx vercel --prod

Connect the repository in the Vercel dashboard for automatic deploys on every push, then add your domain under Project → Settings → Domains. Point your registrar’s DNS at Vercel’s nameservers (or add the provided A/CNAME records), and keep the old WordPress host running read-only for a few days as a safety net until you’ve confirmed every redirect and every indexed URL resolves correctly.

Skip the 40+ Hours

Every step above is real and doable yourself. It’s also the exact process we run for clients at no upfront cost — migrated and live in about 14 days, redirects and all.

A companion reference repository with the code patterns from this guide is available at github.com/higgipop/wp-to-nextjs-migration-starter.