← All articles
Headless WordPress With a Modern Next.js Frontend
Decouple WordPress and render a fast Next.js frontend — fetch posts and pages via the REST API or WPGraphQL, wire ISR, media, SEO, preview, and dodge the auth/CORS/cache gotchas.

Most WordPress sites don't need a new CMS — they need a new frontend. If your client already lives in the WordPress dashboard but the PHP theme is slow, rigid, and impossible to animate, the move is to go headless WordPress with a modern Next.js frontend: keep WordPress purely as the editor's content back end and render the actual site in Next.js. This is the migrate-off-theme path I've shipped on real client work — including Iventions, built with studio SERIOUS.BUSINESS and designer Huy Phan. Below is the whole pipeline: fetching posts and pages, ISR and tag revalidation, media and SEO, preview, and the auth/CORS/cache gotchas that trip up most first builds.
A quick credit note: on Iventions I was the developer; the design and art direction were Huy Phan + SERIOUS.BUSINESS. Everything here is the engineering side of that build.
Why go headless WordPress (and keep the theme in the bin)
The instinct of a lot of developers is to reach for a sleek headless CMS (Sanity, Payload) and never touch WordPress again. But on real projects the deciding factor is rarely developer taste — it's who edits the content after launch. An enormous number of marketing teams, agencies, and founders already live in WordPress every day, and retraining them onto a new tool is a real cost.
"Headless" means WordPress keeps the dashboard, the media library, the roles, and the editorial workflow — but you throw away its theme layer entirely. The site itself becomes a Next.js app that reads content over an API. The split is the whole pitch:
- Editors keep WordPress. Zero retraining, mature permissions, a huge plugin ecosystem for SEO, forms, and custom fields.
- Users get a fast, bespoke frontend. React Server Components, granular caching, code-split interactivity, and a motion system you actually own.
- The two are decoupled. Redesign the frontend without migrating content, or swap the CMS later without touching the UI. That's the real "future-proof" part.
This is one of the delivery models I cover in what a fullstack creative developer delivers — owning both the editor's back end and the user's frontend, from one partner.
Two documented ways to read content: REST vs WPGraphQL
WordPress ships a REST API out of the box, and WPGraphQL adds a typed GraphQL endpoint via a plugin. Both are stable, documented paths — pick based on how field-precise your components are.
| Approach | What it is | Best when |
|---|---|---|
| WP REST API | Built-in /wp-json endpoints | Zero extra plugins, simple cacheable URLs, standard posts/pages |
| WPGraphQL | A GraphQL endpoint over WP + ACF | You want exactly the fields a component needs in one request |
I reach for WPGraphQL on block-heavy, art-directed builds because a component-driven frontend
pairs naturally with field-precise queries — a Hero block asks for its headline, its media, and
its config in a single round trip. On a standard blog-plus-pages site, the REST API is perfectly
fine and one less moving part. The rest of this guide uses REST because it needs no plugin and the
URLs are trivially cacheable.
Fetching posts and pages from the WordPress REST API
In the App Router, fetching is a server concern — WordPress credentials and query weight never reach the browser. Here's a paginated post list. The gotcha most people miss: pagination lives in the response headers, not the body.
// lib/wp.ts — runs only on the server
const WP = process.env.WP_URL // e.g. https://cms.example.com
export async function getPosts(page = 1) {
const res = await fetch(`${WP}/wp-json/wp/v2/posts?per_page=12&page=${page}&_embed`, {
// ISR: serve cached HTML, refresh in the background every 5 min
next: { revalidate: 300, tags: ['posts'] },
})
if (!res.ok) throw new Error('WP posts fetch failed')
return {
posts: await res.json(),
totalPages: Number(res.headers.get('X-WP-TotalPages') ?? 1),
}
}
The &_embed flag is doing quiet heavy lifting — it inlines the featured image, author, and taxonomy
terms into an _embedded object so you don't fire three more requests per card. Fetching a single
post or page by slug is the same shape:
export async function getPost(slug: string) {
const res = await fetch(`${WP}/wp-json/wp/v2/posts?slug=${slug}&_embed`, {
next: { revalidate: 300, tags: [`post:${slug}`] },
})
const [post] = await res.json() // slug query returns an array
return post ?? null
}
// pages live at /wp-json/wp/v2/pages?slug=...
That next: { revalidate, tags } line is the most important part of the whole architecture — it's
what makes a WordPress-backed site hit the same Core Web Vitals as a fully static one. More on it
below.
Media, featured images, and SEO fields
With _embed, the featured image and its responsive sizes come back inside the payload. Map the raw
REST shape into a clean object your components consume, and pull the SEO fields your client's plugin
exposes — Yoast and Rank Math both inject structured SEO into the REST response (Yoast as a
yoast_head_json object) once enabled:
function mapPost(p: WpPost) {
const media = p._embedded?.['wp:featuredmedia']?.[0]
return {
title: p.title.rendered,
html: p.content.rendered, // render with a sanitizer, not raw dangerouslySetInnerHTML on trust
cover: media?.source_url,
coverAlt: media?.alt_text ?? '',
sizes: media?.media_details?.sizes, // thumbnail / medium / large / full
seo: p.yoast_head_json, // title, description, og_image, canonical…
}
}
Those SEO fields feed Next.js generateMetadata directly, so the editor's Yoast settings still
drive <title>, description, and Open Graph on a headless frontend:
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost((await params).slug)
const seo = post?.yoast_head_json
return {
title: seo?.title ?? post?.title?.rendered,
description: seo?.description,
openGraph: { images: seo?.og_image ?? [] },
alternates: { canonical: seo?.canonical },
}
}
For the images themselves, run WordPress media URLs through next/image with a remotePatterns
entry for the CMS host so you still get automatic resizing and modern formats on the frontend.
ISR and tag revalidation: fast content that updates in seconds
The pitch of headless is only true if edits actually appear without a redeploy. The pattern: cache
hard with next: { revalidate, tags }, then let a WordPress webhook bust the exact tag on publish.
The Next.js caching docs back this — serve static,
CDN-cached HTML while still refreshing content on demand.
// app/api/revalidate/route.ts — WordPress calls this on save_post
import { revalidateTag } from 'next/cache'
export async function POST(req: Request) {
const { secret, type, slug } = await req.json()
if (secret !== process.env.WP_REVALIDATE_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
revalidateTag(type === 'page' ? `page:${slug}` : `post:${slug}`)
revalidateTag('posts') // refresh any listing that includes it
return Response.json({ revalidated: true })
}
Wire the WordPress side with a small save_post hook (or a plugin like WP Webhooks) that POSTs the
slug and a shared secret. Now an editor hits Publish and the change is live in seconds — same
editing experience, static-site speed. This is the same tag-based discipline I use in this site's own
CMS; I wrote up the failure mode of getting it wrong in why an uncached CMS fetch quietly costs
you.
Preview: showing drafts with Draft Mode + auth
A headless draft no longer "just shows up" — the biggest editor complaint on rushed builds. You need
a preview route that enters Next.js Draft Mode and fetches uncached, authenticated content.
Point WordPress's preview link at it (via the preview_post_link filter) with a shared secret:
// app/api/preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
if (searchParams.get('secret') !== process.env.WP_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 })
}
;(await draftMode()).enable()
redirect(`/${searchParams.get('slug')}`)
}
Then, in your fetch, branch on Draft Mode: when it's on, request draft status with an auth header and
skip the cache so the editor sees the latest keystroke. In Next.js 15+ fetch is no longer
cached by default, so being explicit both ways matters:
import { draftMode } from 'next/headers'
export async function getPostForRoute(slug: string) {
const { isEnabled } = await draftMode()
const status = isEnabled ? 'draft,publish' : 'publish'
return fetch(`${WP}/wp-json/wp/v2/posts?slug=${slug}&status=${status}&_embed`,
isEnabled
? { cache: 'no-store', headers: { Authorization: wpAuthHeader() } }
: { next: { revalidate: 300, tags: [`post:${slug}`] } },
).then((r) => r.json())
}
const wpAuthHeader = () =>
'Basic ' + Buffer.from(`${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}`).toString('base64')
That WP_APP_PASSWORD is a per-user Application Password (WordPress 5.6+) — the documented way to
authenticate REST requests over HTTPS without shipping the login. For WPGraphQL preview you'd use the
JWT Authentication plugin instead.
The real gotchas: auth, CORS, and caching
Three things break almost every first headless build. None are hard once you've hit them:
- CORS is usually self-inflicted. Fetch WordPress from the server (RSC, route handlers) and
there's no CORS at all — it's server-to-server. CORS errors mean you're calling
/wp-jsonfrom the browser; move the call server-side and it evaporates. Keep credentials off the client while you're at it. - Caching flipped in Next.js 15.
fetchis uncached by default now, so a naive port will hammer WordPress on every request. Always be explicit:next: { revalidate, tags }for content,cache: 'no-store'only for preview. I dug into the cost of getting this wrong in the uncached CMS fetch post and keeping a database under its connection limit. - Plugins assume a theme. Some WordPress plugins inject markup that never reaches a headless frontend. Vet SEO/forms plugins for headless/REST support before committing — Yoast and Rank Math are safe because they expose REST fields; a page-builder that only outputs a PHP template is not.
When headless WordPress is — and isn't — worth it
Headless is the right tool surprisingly often, but it isn't free. The honest call:
Worth it when: the client is committed to WordPress as their editor, there's real editorial volume, and the frontend is too custom, fast, or animated for a theme — a brand-flagship or content-rich site. That was exactly Iventions.
Overkill when: it's a small brochure site with rare updates. Running WordPress hosting and a Next.js deploy is two systems, two update cadences, two security surfaces. A static build from Markdown, or a lightweight structured CMS, is less to maintain. If you're choosing a CMS from scratch rather than inheriting WordPress, weigh it against the alternatives in the headless CMS comparison or the modern-editor path in building a Next.js site on Sanity.
Bonus: the animated frontend on top
Because Next.js owns rendering, the frontend can be as art-directed as you like without WordPress
knowing. On Iventions that meant GSAP choreography and a Three.js layer — kept at 60fps by animating
only transform/opacity, lazy-initialising WebGL near the viewport, and capping the device pixel
ratio at Math.min(devicePixelRatio, 2). I go deep on those budgets in Core Web Vitals for
animation-heavy sites. The point stands for a plain
content site too: a fast WordPress frontend is mostly about not shipping work to the client — cache
hard, hydrate little.
Proof it works
Iventions — built headless on WordPress with a Next.js frontend — won CSS Design Awards Website of the Month and Awwwards Site of the Day + Developer Award, with the editorial team managing content in WordPress the whole time. The full story is in the Iventions case study, and more shipped work is in the projects archive.
FAQ
Should I use the WordPress REST API or WPGraphQL with Next.js?
Use the REST API for standard posts/pages when you want zero extra plugins and simple cacheable URLs. Use WPGraphQL when you want field-precise queries per component — ideal for block-based, ACF-heavy, art-directed builds where each section fetches exactly what it needs.
How do I preview WordPress drafts on a headless Next.js frontend?
Add a preview route that calls draftMode().enable(), point WordPress's preview link at it with a
shared secret, and in Draft Mode fetch with cache: 'no-store' plus an Application Password auth
header so the editor sees the latest unpublished content.
Why am I getting CORS errors from the WordPress REST API?
Because you're fetching /wp-json from the browser. Move the fetch to the server — RSC, a route
handler, or generateMetadata — and it's server-to-server with no CORS at all. As a bonus your
WordPress credentials never reach the client.
Does headless WordPress hurt SEO or performance?
No, if you cache correctly. With Next.js ISR (revalidate + tag invalidation) you serve static,
CDN-cached HTML and refresh on publish via a webhook — matching a fully static site's speed. Yoast or
Rank Math SEO fields still drive your metadata through generateMetadata.
Can I migrate an existing WordPress site to a headless frontend without moving content?
Yes — that's the main appeal. The content, users, and media stay in WordPress untouched; you only replace the theme with a Next.js app that reads the same REST/GraphQL API. You can even run the new frontend on a subdomain during the cutover.
Let's build it
If you have a WordPress site whose theme is holding it back — or you're commissioning one and want it fast, animated, and editable — decoupling it into a Next.js frontend is exactly what I do as a fullstack creative developer.
- See how I work and engagement tiers on the services page.
- Browse shipped, awarded work in the projects archive.
- Ready to talk? Let's talk →
Written by Hon Tran — creative developer, founder of hontran.dev, and Awwwards jury member. 11+ years building award-winning, performance-first web experiences (Next.js, GSAP, Three.js / WebGL) for clients worldwide. hontran.dev · Behance.


