← All articles

[ Blog ]

June 27, 2026

11 min read

Category: Insights

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.

Next.jsWordPressHeadless CMSWPGraphQLREST APIArchitecture
Headless WordPress With a Modern Next.js Frontend

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.

ApproachWhat it isBest when
WP REST APIBuilt-in /wp-json endpointsZero extra plugins, simple cacheable URLs, standard posts/pages
WPGraphQLA GraphQL endpoint over WP + ACFYou 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.

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-json from 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. fetch is 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.


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.

Related posts