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.
| Factor | Simply Static Plugin | Manual DIY Next.js | ULTRA Redesigned |
|---|---|---|---|
| Upfront cost | $0–$60/yr | $0 (your time) | $0 upfront |
| Typical timeline | A few hours | 40+ engineering hours | ~14 days, hands-off |
| What you get | A frozen HTML snapshot of your existing WP markup | A real, custom-built Next.js application | A real, custom-built Next.js application |
| Core Web Vitals ceiling | Improved, but capped by WP’s original bloated markup | Effectively unlimited — you control every byte shipped | Effectively unlimited |
| Forms, search, WooCommerce carts | Break — these need a live PHP backend the export doesn’t have | Fully supported — you rebuild them as components | Fully rebuilt for you |
| 301 redirects & permalinks | Still your responsibility to maintain in .htaccess | You write and test the redirect map yourself | Mapped and preserved as part of the build |
| Ongoing maintenance | You — WordPress core still needs updates to regenerate the export | You — hosting, dependency updates, monitoring | ULTRA — 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:
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
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:
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
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
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:
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:
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
git remote add origin https://github.com/your-username/your-repo.git
git push -u origin main
npx vercel --prodConnect 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.