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.
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:
cms.example.com, used only by editors and your frontend.www.example.com, fetching content at build time or on a revalidation schedule.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-htmlAdd your WordPress URL to .env.local:
WORDPRESS_URL=https://cms.example.com
REVALIDATE_SECRET=change-me-to-a-long-random-stringStep 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.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:
preview_post_link filter) to point at a Next.js route such as /api/preview?id=123&secret=....(await draftMode()).enable(), and redirects to the post page.(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
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.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.sitemap.xml from the API in Next.js instead, and recreate any redirects from the old theme's URL structure before switching DNS.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:
Common mistakes
content.rendered without sanitizing. Treat CMS HTML as untrusted input.per_page=100 on every request. Use _fields, pagination and caching; WordPress is usually the slowest part of the stack.X-WP-Total and X-WP-TotalPages response headers. They tell you how many pages to fetch for generateStaticParams and sitemaps.
