intermediate11 min readPart 1 of 1

Headless WordPress with Next.js: REST API, WPGraphQL and ISR

Use WordPress as a headless CMS with the Next.js App Router: REST API vs WPGraphQL, ISR and on-demand revalidation, safe HTML rendering, images, previews, auth and SEO.

TechLogHub
·

Prerequisites

  • •A WordPress site you can administer (5.6 or later)
  • •Working knowledge of React and the Next.js App Router
  • •Node.js installed locally

What You'll Learn

  • Explain headless WordPress and choose between the REST API and WPGraphQL
  • Fetch posts in Next.js with ISR, generateStaticParams and on-demand revalidation
  • Render WordPress HTML safely and serve its images with next/image
  • Handle drafts, application-password auth, CORS and SEO, and know when not to go headless

Headless WordPress means keeping WordPress for what it is good at, writing and managing content, and replacing its PHP theme with a separate frontend that pulls content over an API. Editors keep the admin they know; developers get a modern stack, faster pages and freedom from theme code. This tutorial connects WordPress to a Next.js App Router site: fetching posts, static generation with revalidation, safe HTML rendering, images, previews, auth and SEO, plus an honest look at when you should not do it.

What headless WordPress is

In a normal install, WordPress stores content and renders HTML pages. In a headless setup WordPress becomes a headless CMS: it only stores content and exposes it as JSON, and another application renders the pages. In practice:

  • WordPress lives on a subdomain such as cms.example.com, used only by editors and your frontend.
  • Next.js serves the public site at www.example.com, fetching content at build time or on a revalidation schedule.
  • Visitors never touch WordPress directly, which shrinks its attack surface and load.
  • REST API vs WPGraphQL

    WordPress has two realistic ways to expose content.

    The built-in REST API needs nothing installed. Every site already answers at /wp-json/wp/v2/:

  • /wp-json/wp/v2/posts lists published posts (10 per page by default, up to 100 with per_page).
  • /wp-json/wp/v2/posts?slug=hello-world finds a post by slug.
  • _fields=id,slug,title trims the response to what you need, and _embed inlines the author and featured image.
  • WPGraphQL is a free plugin that adds a single /graphql endpoint. You ask for exactly the fields you need, including related data, in one request:

    query PostBySlug($slug: ID!) {
      post(id: $slug, idType: SLUG) {
        title
        date
        content
        featuredImage {
          node {
            sourceUrl
            altText
          }
        }
      }
    }

    Pick the REST API when your content is simple posts and pages and you want zero plugins. Pick WPGraphQL when you have custom post types, Advanced Custom Fields (via the WPGraphQL for ACF extension) or deeply related content where REST needs several round-trips. This tutorial uses REST so it works on any WordPress site; the Next.js side is the same either way.

    Step 1: set up the Next.js project

    Create the app and install an HTML sanitizer:

    npx create-next-app@latest wp-frontend --typescript --app
    cd wp-frontend
    npm install sanitize-html
    npm install -D @types/sanitize-html

    Add your WordPress URL to .env.local:

    WORDPRESS_URL=https://cms.example.com
    REVALIDATE_SECRET=change-me-to-a-long-random-string

    Step 2: a typed WordPress client

    Create lib/wordpress.ts:

    const WP_URL = process.env.WORDPRESS_URL;
    export type WPPost = {
      id: number;
      slug: string;
      date: string;
      modified: string;
      title: { rendered: string };
      excerpt: { rendered: string };
      content: { rendered: string };
      _embedded?: {
        "wp:featuredmedia"?: { source_url: string; alt_text: string; media_details?: { width: number; height: number } }[];
      };
    };
    async function wpFetch<T>(path: string, revalidate = 300): Promise<T> {
      const res = await fetch(WP_URL + "/wp-json/wp/v2" + path, {
        next: { revalidate },
      });
      if (!res.ok) {
        throw new Error("WordPress request failed: " + res.status + " " + path);
      }
      return res.json() as Promise<T>;
    }
    export function getPosts() {
      return wpFetch<WPPost[]>("/posts?per_page=20&_fields=id,slug,date,title,excerpt");
    }
    export async function getPostBySlug(slug: string) {
      const posts = await wpFetch<WPPost[]>("/posts?slug=" + encodeURIComponent(slug) + "&_embed");
      return posts[0] ?? null;
    }
    export function getAllSlugs() {
      return wpFetch<{ slug: string }[]>("/posts?per_page=100&_fields=slug");
    }

    next: { revalidate: 300 } is incremental static regeneration (ISR) at the fetch level: Next.js caches the response, serves it instantly, and refreshes it in the background at most every five minutes. Since Next.js 15, fetch is not cached by default, so the explicit revalidate matters. If you have enabled Cache Components in Next.js 16, use the "use cache" directive with cacheLife instead; the rest of this tutorial is unchanged.

    Step 3: the blog index

    Create app/blog/page.tsx:

    import Link from "next/link";
    import sanitizeHtml from "sanitize-html";
    import { getPosts } from "@/lib/wordpress";
    export const revalidate = 300;
    export default async function BlogPage() {
      const posts = await getPosts();
      return (
        <main className="mx-auto max-w-3xl p-6">
          <h1 className="text-3xl font-bold">Blog</h1>
          <ul className="mt-8 space-y-8">
            {posts.map((post) => (
              <li key={post.id}>
                <Link href={"/blog/" + post.slug} className="text-xl font-semibold">
                  {sanitizeHtml(post.title.rendered, { allowedTags: [], allowedAttributes: {} })}
                </Link>
                <p>{sanitizeHtml(post.excerpt.rendered, { allowedTags: [], allowedAttributes: {} })}</p>
              </li>
            ))}
          </ul>
        </main>
      );
    }

    WordPress returns titles and excerpts as HTML (with encoded entities for apostrophes and quotes, plus paragraph tags). Stripping all tags gives plain text that React escapes safely. Remaining entities can be decoded with a small helper or a library such as he.

    Step 4: dynamic post pages with generateStaticParams

    Create app/blog/[slug]/page.tsx:

    import Image from "next/image";
    import { notFound } from "next/navigation";
    import sanitizeHtml from "sanitize-html";
    import { getAllSlugs, getPostBySlug } from "@/lib/wordpress";
    export const revalidate = 300;
    export async function generateStaticParams() {
      const posts = await getAllSlugs();
      return posts.map((post) => ({ slug: post.slug }));
    }
    const sanitizeOptions: sanitizeHtml.IOptions = {
      allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img", "figure", "figcaption", "h1", "h2"]),
      allowedAttributes: {
        ...sanitizeHtml.defaults.allowedAttributes,
        img: ["src", "srcset", "sizes", "alt", "width", "height", "loading"],
      },
      allowedSchemes: ["https", "mailto"],
    };
    export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
      const { slug } = await params;
      const post = await getPostBySlug(slug);
      if (!post) notFound();
      const image = post._embedded?.["wp:featuredmedia"]?.[0];
      const html = sanitizeHtml(post.content.rendered, sanitizeOptions);
      return (
        <article className="prose mx-auto max-w-3xl p-6">
          <h1>{sanitizeHtml(post.title.rendered, { allowedTags: [], allowedAttributes: {} })}</h1>
          <time dateTime={post.date}>{new Date(post.date).toLocaleDateString("en-US")}</time>
          {image ? (
            <Image
              src={image.source_url}
              alt={image.alt_text}
              width={image.media_details?.width ?? 1200}
              height={image.media_details?.height ?? 630}
              priority
            />
          ) : null}
          <div dangerouslySetInnerHTML={{ __html: html }} />
        </article>
      );
    }

    Key points:

  • generateStaticParams pre-renders up to 100 recent posts at build time. Posts published later are rendered on first request and then cached, because dynamic params are allowed by default. For sites with thousands of posts, return only the most popular ones and let the rest render on demand.
  • params is a Promise in current Next.js versions, so you await it.
  • Sanitize before dangerouslySetInnerHTML. Post content comes from any user who can edit posts, plus every plugin that filters content. Without sanitizing, one compromised editor account becomes stored cross-site scripting on your public site. sanitize-html runs on the server with no browser DOM, which suits server components.
  • Step 5: images

    next/image refuses remote images unless you allow the host. In next.config.ts:

    import type { NextConfig } from "next";
    const nextConfig: NextConfig = {
      images: {
        remotePatterns: [{ protocol: "https", hostname: "cms.example.com" }],
      },
    };
    export default nextConfig;

    You can narrow the rule further with a pathname pattern for the uploads folder. Images inside the post body stay as plain HTML image tags with WordPress's own srcset, which is fine; if you want them optimised too, parse the HTML with a library such as html-react-parser and replace each image with next/image.

    Step 6: instant updates with on-demand revalidation

    Waiting up to five minutes after hitting Publish annoys editors. Add a webhook endpoint in app/api/revalidate/route.ts:

    import { revalidatePath } from "next/cache";
    import { NextRequest, NextResponse } from "next/server";
    export async function POST(req: NextRequest) {
      if (req.headers.get("x-revalidate-secret") !== process.env.REVALIDATE_SECRET) {
        return NextResponse.json({ ok: false }, { status: 401 });
      }
      const body = await req.json().catch(() => ({}));
      revalidatePath("/blog");
      if (body.slug) revalidatePath("/blog/" + body.slug);
      return NextResponse.json({ ok: true });
    }

    Then call it from WordPress with a small must-use plugin, wp-content/mu-plugins/revalidate.php (define REVALIDATE_SECRET in wp-config.php):

    <?php
    add_action('save_post_post', function ($post_id, $post) {
        if (wp_is_post_revision($post_id) || $post->post_status !== 'publish') {
            return;
        }
        wp_remote_post('https://www.example.com/api/revalidate', [
            'headers' => ['Content-Type' => 'application/json', 'x-revalidate-secret' => REVALIDATE_SECRET],
            'body' => wp_json_encode(['slug' => $post->post_name]),
            'timeout' => 5,
        ]);
    }, 10, 2);

    Keep the time-based revalidate as a safety net in case a webhook call fails.

    Step 7: previews, drafts and authentication

    Public endpoints only return published content. To read drafts, authenticate with an application password, built into WordPress since 5.6: go to Users > Profile > Application Passwords, create one for a dedicated low-privilege editor account, and send it as HTTP Basic auth over HTTPS (Authorization: Basic followed by the Base64 of username:application-password). Keep that header server-side only; never ship it to the browser.

    For previews, the usual flow is:

  • Change WordPress's preview link (the preview_post_link filter) to point at a Next.js route such as /api/preview?id=123&secret=....
  • That route checks the secret, enables Next.js Draft Mode with (await draftMode()).enable(), and redirects to the post page.
  • The post page checks (await draftMode()).isEnabled and, if true, fetches /wp-json/wp/v2/posts/123?context=edit with the application password and no caching.
  • CORS

    Server components and route handlers fetch WordPress from the server, so CORS does not apply. You only need CORS headers if browser JavaScript calls WordPress directly (for example a client-side search box). In that case allow only your frontend origin, never a wildcard combined with credentials.

    SEO considerations

  • Only one site should be indexable. Block search engines from cms.example.com (Settings > Reading, or a noindex header at the server) so the WordPress frontend does not compete with your Next.js pages as duplicate content.
  • Rebuild metadata in Next.js. Use generateMetadata for titles, descriptions and canonical URLs. If you use Yoast SEO, its yoast_head_json field in REST responses gives you the SEO title, description and Open Graph data editors entered.
  • Own your sitemap and redirects. WordPress's sitemap lists CMS URLs; generate sitemap.xml from the API in Next.js instead, and recreate any redirects from the old theme's URL structure before switching DNS.
  • Keep permalinks identical to the old site when migrating, or you lose rankings you already earned.
  • When not to go headless

    Headless WordPress is more moving parts: two deployments, two sets of updates, a webhook to keep healthy. Skip it when:

  • The site depends on plugins that render frontend output, such as page builders, form plugins, membership or WooCommerce checkout. Each one has to be rebuilt in React.
  • Editors rely on visual editing and live preview exactly as they see it today.
  • There is no developer to maintain the frontend after launch.
  • A well-cached classic theme is already fast enough. Headless is a means, not a goal.
  • Common mistakes

  • Rendering content.rendered without sanitizing. Treat CMS HTML as untrusted input.
  • Fetching all posts with per_page=100 on every request. Use _fields, pagination and caching; WordPress is usually the slowest part of the stack.
  • Forgetting the X-WP-Total and X-WP-TotalPages response headers. They tell you how many pages to fetch for generateStaticParams and sitemaps.
  • Leaving the WordPress frontend indexable, creating duplicate content on the CMS subdomain.
  • Exposing application passwords to the browser, which hands out editor access to anyone who opens dev tools.
  • Where to go next

  • Compare architectures in the headless CMS glossary entry.
  • Containerise the Next.js frontend with the Docker cheat sheet and deploy it from a pipeline like the GitHub Actions CI/CD tutorial.
  • Inspect API responses while you build with the JSON formatter.
  • headless wordpressnext.jswpgraphqlheadless cmsweb development

    Keep the references coming

    One email a week: new and trending developer tools, fresh comparisons, and what shipped. Unsubscribe in one click.