The problem with a busy WordPress site
Picture a regional news site. Twenty editors, a few thousand articles, a Gutenberg workflow everyone already knows. On a normal day it is fine. Then a story breaks, traffic goes 20× in an hour, and PHP-FPM and MySQL fall over while the homepage is trying to render the same query for every one of those visitors. The cache plugin helps until it does not, and the one afternoon you actually needed the site, it served 503s.
Underneath that is a second, quieter problem: wp-login.php and xmlrpc.php are sitting on the public internet, and bots find them within minutes of a domain going live. We open most forensic jobs on exactly this pattern — see the 217-plugin vulnerability audit for what gets through. And the reason the homepage is slow in the first place is rarely the host; it is the front end, as the 100-site performance study found across the board.
Going headless solves all three at once — speed, uptime, and attack surface — without throwing away the editor your team already lives in. This is the architecture we ship on a headless WordPress engagement, and the rest of this guide is how to build it.
What “headless” actually means here
A normal WordPress install does two jobs: it stores and edits content (the admin, the database, Gutenberg) and it renders the public HTML (the theme, PHP templates, plugins on the front end). Headless keeps the first job and deletes the second. WordPress becomes a content API; a separate Next.js app becomes the only thing the public ever sees.
- WordPress (private origin) — wp-admin, the database, media uploads, the REST API. Lives on a locked-down hostname like
cms.example.comthat is never advertised. - Next.js (public front end) — pulls content at build and on a schedule, renders static HTML, serves from a global CDN at
www.example.com.
The two only talk over HTTPS+JSON, on your terms, with credentials. Nothing else about the editorial workflow changes — editors still write in Gutenberg and hit Publish.
What WordPress already gives you for free
The reason this is so achievable is that WordPress ships a complete read API out of the box at /wp-json. You do not install anything to get posts, pages, categories, tags, media and authors as JSON. Custom post types join the API the moment they are registered with show_in_rest.
# Read-only JSON endpoints exposed by default:
curl "https://cms.example.com/wp-json/wp/v2/posts?per_page=10&_embed"
curl "https://cms.example.com/wp-json/wp/v2/pages"
curl "https://cms.example.com/wp-json/wp/v2/categories"
curl "https://cms.example.com/wp-json/wp/v2/tags"
curl "https://cms.example.com/wp-json/wp/v2/media/123"
curl "https://cms.example.com/wp-json/wp/v2/users"
# _embed inlines the featured image, author and terms so you avoid N+1 calls.
# Custom post types appear automatically once registered for REST:
curl "https://cms.example.com/wp-json/wp/v2/breaking-news"<?php
add_action('init', function () {
register_post_type('breaking_news', [
'label' => 'Breaking News',
'public' => true,
'has_archive' => true,
'show_in_rest' => true, // <- this line is what creates the JSON route
'rest_base' => 'breaking-news',
'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'custom-fields'],
]);
});Two things the raw REST API does not hand you cleanly are the navigation menus and deeply structured field data. For both, the answer on a serious build is WPGraphQL — one plugin that turns the whole site into a typed GraphQL schema, including menus (via WPGraphQL Menus), Advanced Custom Fields (via WPGraphQL for ACF), and exactly-the-fields-you-asked-for responses that keep payloads small. REST is perfectly fine for a simple blog; WPGraphQL is what we reach for once menus, ACF and CPT relationships are in play.
Mapping the site structure to Next.js routes
Everything the old theme rendered has a one-to-one home in the Next.js App Router. You are re-creating the same URL structure so existing links and rankings carry over — URL parity is the whole game in a migration, the same discipline behind a tech-stack migration.
| WordPress concept | REST / GraphQL source | Next.js route |
|---|---|---|
| Single post | /wp/v2/posts?slug= | app/[slug]/page.tsx |
| Page | /wp/v2/pages?slug= | app/[...path]/page.tsx |
| Category / tag archive | /wp/v2/posts?categories= | app/category/[slug]/page.tsx |
| Custom post type | /wp/v2/breaking-news | app/news/[slug]/page.tsx |
| Pagination | ?page=2&per_page=20 + X-WP-TotalPages header | app/page/[n]/page.tsx |
| Nav menu | WPGraphQL menuItems | Shared <SiteHeader /> server component |
Scaffolding the build with an AI coding agent
This is the part that used to take a fortnight and now takes an afternoon. The WordPress REST schema is self-describing, so you can point an AI coding agent — Claude Code is what we use — straight at /wp-json and have it generate the types, the data layer and the route tree from the live site. It is the same prototype-to-production motion we describe in the Lovable-to-production teardown, applied to a CMS migration.
> Read https://cms.example.com/wp-json and list every post type, taxonomy and
REST route. Generate src/types/wp.ts with interfaces for WpPost, WpPage,
WpCategory, WpMedia and the breaking-news CPT.
> Create lib/wp.ts: a typed fetch wrapper with Basic-Auth from env and Next ISR
cache tags. Add getPostBySlug, getAllPostSlugs, getPostsByCategory, getMenu.
> Generate the App Router tree: app/[slug] for posts, app/category/[slug] for
archives with pagination, app/news/[slug] for the CPT. Add generateStaticParams
to each.
> Build a GutenbergBlocks renderer that maps WordPress block HTML to our
design-system components, and wire app/api/revalidate + a WP MU-plugin that
POSTs to it on save_post.Treat the output the way you would treat any junior engineer's PR — read every diff, keep the decisions. The leverage is real, but the review is not optional; it is exactly the discipline the AI-prototype codebase audit found separates the prototypes that survive production from the ones that do not.
The data layer
One thin wrapper around fetch is the entire integration. The two things that matter: an Authorization header so a private WordPress will answer, and Next's next.revalidate / cache tags so every response is cached and refreshed on a schedule rather than re-fetched per request.
const WP = process.env.WP_API_URL!; // https://cms.example.com/wp-json
const AUTH = process.env.WP_BASIC_AUTH; // base64 "user:app_password" — server only
export async function wp<T>(path: string, revalidate = 300): Promise<T> {
const res = await fetch(WP + path, {
headers: AUTH ? { Authorization: "Basic " + AUTH } : {},
next: { revalidate, tags: ["wp"] }, // ISR: cache + background refresh
});
if (!res.ok) throw new Error("WP " + res.status + " for " + path);
return res.json() as Promise<T>;
}
export const getPostBySlug = (slug: string) =>
wp<WpPost[]>("/wp/v2/posts?slug=" + slug + "&_embed").then((r) => r[0]);
export const getAllPostSlugs = () =>
wp<WpPost[]>("/wp/v2/posts?per_page=100&_fields=slug").then((r) => r.map((p) => p.slug));Rendering: static at build, fresh on publish
Each post is statically generated at build and then served as plain HTML from the CDN edge. generateStaticParams enumerates every slug so they are all pre-rendered; revalidate makes the page Incremental Static Regeneration (ISR), so it refreshes in the background on a window. The full breakdown of which rendering mode to use where is in the App Router SSR-for-SEO guide — for a content site, the answer is almost always static + ISR.
import { notFound } from "next/navigation";
import { getPostBySlug, getAllPostSlugs } from "@/lib/wp";
export const revalidate = 300; // a ceiling — on-demand revalidation makes it instant
export async function generateStaticParams() {
const slugs = await getAllPostSlugs();
return slugs.map((slug) => ({ slug }));
}
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) notFound();
return (
<article>
<h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
<div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
</article>
);
}The revalidate = 300 window is a safety net, not the real refresh path. For a news site, “up to 5 minutes stale” is not good enough — so WordPress tells Next the instant anything changes. A tiny must-use plugin fires on save_post and pings a revalidation endpoint, which drops the cached responses for that content. Editors hit Publish; the live page updates in a second or two.
import { NextRequest, NextResponse } from "next/server";
import { revalidateTag } from "next/cache";
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ ok: false }, { status: 401 });
}
revalidateTag("wp"); // next request for that content rebuilds from WP
return NextResponse.json({ ok: true, revalidated: true });
}<?php
add_action('save_post', function ($post_id, $post) {
if (wp_is_post_revision($post_id) || $post->post_status !== 'publish') return;
$url = 'https://www.example.com/api/revalidate?secret=' . REVALIDATE_SECRET;
wp_remote_post($url, ['blocking' => false, 'timeout' => 2]);
}, 10, 2);Hosting and deployment
The two halves deploy independently, which is half the point.
- The Next.js front end goes on Vercel, Netlify or Cloudflare Pages. Push to
main, it builds, pre-renders every post, and pushes static assets to the CDN. Pull requests get preview deployments so editors can review a redesign on a real URL. - The WordPress origin stays on whatever cheap, boring host it is already on — it now serves JSON to one consumer instead of HTML to the world, so it barely breaks a sweat. The static media can move to object storage; we cover exactly that in Cloudflare R2 as the WordPress media library.
Config is a handful of environment variables on the front end: WP_API_URL, WP_BASIC_AUTH (or a Cloudflare Access service token) and REVALIDATE_SECRET. The edge and caching layer — cache rules, WAF, redirects — is Cloudflare edge engineering territory, and it is what turns “a Next.js app” into “a site that does not go down.”
Locking the WordPress origin away
This is the security win, and it is structural rather than a plugin you bolt on. Once the public front end is static, nothing on the internet needs to reach WordPress except your build and your revalidation webhook. So you take WordPress off the public internet.
- Move it to its own hostname —
cms.example.com, never linked from anywhere public. - Require auth on the whole origin. HTTP Basic Auth is the simplest; the front end sends the matching header. Better still, put it behind Cloudflare Access (Zero Trust) with a service token the Next.js app presents.
- Use Application Passwords for the REST credentials, scoped to a read-only user.
- Disable XML-RPC and user enumeration, and 2FA the admin. Defence in depth for the rare authorised session.
AuthType Basic
AuthName "Restricted CMS"
AuthUserFile /var/www/.htpasswd
Require valid-user
# The Next.js build/ISR sends the matching Authorization header (WP_BASIC_AUTH).
# Optional belt-and-braces: also pin to your build / serverless egress IPs.
# Require ip 203.0.113.0/24Now reason about the attack surface. The brute-force bots hammering wp-login.php get a 401 before they ever reach WordPress — there is no public login form to attack. The plugin vulnerabilities that drive most incidents (the kind we clean up under WordPress security & malware removal) are no longer exposed, because the only public surface is static HTML with no PHP behind it. You have not hardened the login page; you have removed it from the internet.
And the uptime story falls out of the same design. If WordPress is down for maintenance, gets overwhelmed, or simply errors, the static pages keep serving from the CDN exactly as they were — the only thing that pauses is new edits going live. A traffic spike hits the CDN, which is built for it, not PHP-FPM, which is not. The site your readers see is decoupled from the health of the box your editors log into.
Why this is so much faster — and tougher
The gains are not marginal; they come from deleting the per-request PHP+MySQL render and serving pre-built HTML from a CDN node near the reader. Typical before/after we see on content sites:
| Dimension | Traditional WordPress | Headless WP + Next.js |
|---|---|---|
| TTFB (cached) | 400–800 ms (PHP + MySQL per request) | 20–80 ms (static HTML from the edge) |
| Under a 20× traffic spike | PHP-FPM / MySQL saturate → 502 / 503 | CDN absorbs it; the origin sees almost nothing |
| Brute-force / login surface | wp-login.php + xmlrpc.php public | Origin private — no public login to attack |
| If the origin is down | Whole site is down | Static pages keep serving; only edits pause |
| Public attack surface | Every plugin on the request path | Static HTML — no PHP on the public path |
Typical ranges from content-site builds, not a controlled study. The measured WordPress baseline — and why the front end, not the host, is usually the bottleneck — is in the 100-site performance study.
The speed is also an SEO story. Static HTML with the content in the markup is the most indexable shape there is, fast LCP and TTFB are ranking inputs, and there is no JavaScript-rendering gap for Googlebot to trip over — the failure mode we quantified in the JavaScript SEO study and keep fixed under a technical-SEO engagement. Fresh content via on-demand ISR also keeps you on the right side of indexing decay.
The trade-offs, and how we handle them
Headless is not free. The honest list of what gets harder, and the standard answers:
- Preview. Drafts are not public, so editors lose the “Preview” button unless you wire it up. Next.js Draft Mode hits WordPress with auth and renders unpublished content on a private URL — a half-day of work, not a blocker.
- Forms, search and comments. Anything interactive that a plugin used to render needs a home: a form service or a serverless handler, a build-time search index (Pagefind / Algolia) or the WP search endpoint, and a comments service or headless comments API.
- Gutenberg blocks. WordPress returns block HTML; you either ship its stylesheet or map blocks to your own components. This is the bulk of the front-end work and where knowing WordPress deeply earns its keep.
- Frontend-only plugins stop working. Anything that injects into the theme (related-posts widgets, some SEO and AMP plugins) has no theme to inject into. Yoast's data is still readable over the API; its output is not.
- Very large sites. Tens of thousands of posts make a full static build slow — lean on on-demand ISR and only pre-build the hot set, generating the long tail on first request.
If your team genuinely lives inside page-builder plugins for every layout, a headless split fights that workflow, and a focused WordPress performance pass may be the better spend. Headless pays off hardest for content-led sites — news, blogs, docs, marketing — where the editorial model is simple and the read traffic is large. (Greenfield, with no legacy WordPress to keep, a Sanity CMS build is often the cleaner starting point.)
Where this lands
You keep the editor your team knows and the years of content already in it. You hand the public a static, edge-cached front end that is an order of magnitude faster, shrugs off traffic spikes, and stays up even when the CMS does not. And you take the login page — the thing attackers were actually after — off the public internet entirely. That is the whole pitch for headless WordPress, and on a typical news or blog site it is a few weeks of work, not a rebuild from scratch.
■ Related research
Related research
The companion pieces on the rendering, media, performance and SEO sides of this build:
■ Related services
Three engagements that ship this
The headless build itself, the technical-SEO layer that keeps it indexable, and the edge engineering that makes it fast and resilient:
Headless WordPress & WooCommerce
WP + Next.js with preview, ISR, auth handoff, media pipeline, search.
Learn moreTechnical SEO for SaaS
Prerender, schema, Core Web Vitals — engineering-led SEO.
Learn moreCloudflare Edge Engineering
Workers, R2, WAF, Bulk Redirects. The full surface, not just the orange cloud.
Learn moreFrequently asked questions
- Is headless WordPress with Next.js faster than a normal WordPress site?
- Usually by an order of magnitude. A headless front end serves pre-built static HTML from a CDN edge node, so time-to-first-byte drops from roughly 400-800 ms (PHP and MySQL rendering on every request) to about 20-80 ms. The reader is handed a cached file near them instead of waiting for the origin to assemble the page.
- Does converting WordPress to headless Next.js hurt SEO?
- No — done with static generation or server rendering it usually helps. The content ships inside the HTML, which is the most indexable shape there is, and faster LCP and TTFB are ranking inputs. The only thing to avoid is rendering content solely on the client; keep it server-rendered and Googlebot sees everything.
- How does a headless setup stop WordPress brute-force attacks?
- The WordPress origin is moved to a private hostname behind authentication (HTTP Basic Auth or Cloudflare Access), so wp-login.php and xmlrpc.php are no longer on the public internet. Brute-force bots get a 401 before they ever reach WordPress — there is no public login form left to attack.
- What happens to my site if WordPress goes down?
- The public site stays up. Because pages are static and cached at the CDN edge, they keep serving exactly as they were even if the WordPress origin is offline, overwhelmed, or in maintenance. The only thing that pauses is new edits going live, which resume the moment WordPress is back.
- Can editors still use the WordPress editor and preview drafts after going headless?
- Yes. Editors keep writing in Gutenberg and hit Publish as normal; a webhook tells Next.js to refresh the affected pages within a second or two. Draft preview is wired up with Next.js Draft Mode, which fetches unpublished content from WordPress with credentials and renders it on a private URL.
- Do existing WordPress plugins still work after going headless?
- Content and API plugins (custom fields, WPGraphQL, SEO data) keep working because their data is read over the API. Plugins that render into the theme — related-post widgets, page builders, AMP — do not, because there is no public theme for them to inject into. Those features get rebuilt in the Next.js front end.

About the author
Ritesh — Founding Partner, Appycodes
LinkedInCo-authored with Debarshi Dey, Lead WordPress Engineer
Ritesh runs engineering at Appycodes. Debarshi leads the WordPress practice and has taken news, publishing and B2B WordPress sites headless onto Next.js — keeping the editorial team in Gutenberg while moving the public front end to a static, edge-cached, locked-down architecture.
