Architecture guide

    WordPress to headless Next.js — faster, fully cached, and immune to downtime and brute force

    Take a news or blog site running on WordPress. Keep WordPress as the editor, but stop letting the public touch it. A Next.js front end reads the content WordPress already exposes over JSON, renders it as static pages cached at the edge, and refreshes the moment an editor hits Publish. The result is an order of magnitude faster, survives traffic spikes, and removes the login page attackers were hammering — because the public never reaches WordPress at all.

    Jun 2, 202618 min readBy Ritesh + Debarshi
    Headless WordPress feeding a Next.js front end cached at the CDN edge

    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.com that 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.

    The WordPress REST API — available on every install, no plugin required
    bash
    # 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"
    wp-content/mu-plugins/news-cpt.php — a custom type the REST API can see
    php
    <?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 conceptREST / GraphQL sourceNext.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-newsapp/news/[slug]/page.tsx
    Pagination?page=2&per_page=20 + X-WP-TotalPages headerapp/page/[n]/page.tsx
    Nav menuWPGraphQL menuItemsShared <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.

    Driving Claude Code from the live WordPress schema
    text
    Each prompt produces a reviewable diff. You are the editor — the agent does the typing, you keep the architecture decisions.
    > 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.

    lib/wp.ts — one typed wrapper for the whole WordPress API
    typescript
    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.

    app/[slug]/page.tsx — every post becomes an edge-cached static page
    typescript
    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.

    app/api/revalidate/route.ts — WordPress calls this on every publish
    typescript
    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 });
    }
    wp-content/mu-plugins/revalidate-next.php — the publish webhook
    php
    <?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.

    1. Move it to its own hostnamecms.example.com, never linked from anywhere public.
    2. 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.
    3. Use Application Passwords for the REST credentials, scoped to a read-only user.
    4. Disable XML-RPC and user enumeration, and 2FA the admin. Defence in depth for the rare authorised session.
    .htaccess at the WordPress root — the origin answers no one without credentials
    apache
    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/24

    Now 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:

    DimensionTraditional WordPressHeadless WP + Next.js
    TTFB (cached)400–800 ms (PHP + MySQL per request)20–80 ms (static HTML from the edge)
    Under a 20× traffic spikePHP-FPM / MySQL saturate → 502 / 503CDN absorbs it; the origin sees almost nothing
    Brute-force / login surfacewp-login.php + xmlrpc.php publicOrigin private — no public login to attack
    If the origin is downWhole site is downStatic pages keep serving; only edits pause
    Public attack surfaceEvery plugin on the request pathStatic 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.

    Frequently 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.
    Ritesh — Founding Partner, Appycodes

    About the author

    RiteshFounding Partner, Appycodes

    LinkedIn

    Co-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.

    Last reviewed: Jun 2, 2026

    Full stack web and mobile tech company

    Taking the first step is the hardest. We make everything after that simple.

    Let's talk today