The shape of it
WordPress runs on a server nobody visits. Editors log in, write posts and pages in the block editor, upload media, and press Publish. Next.js, on Vercel or similar, fetches that content over an API, renders it into fast static pages, and is what visitors see. WordPress is the editing tool; Next.js is the website.
It gives editors an editor they know and visitors a site that scores like a static one. It also introduces a seam, and the work is in making that seam invisible. Here is how we build it.
REST or WPGraphQL
The REST API ships with WordPress. /wp-json/wp/v2/posts?_embed returns posts with featured media and terms. It is verbose, requires several requests for related data, and returns rendered HTML for content. It works with no plugin.
WPGraphQL is a plugin that exposes a GraphQL schema. One query fetches a post, its author, its terms, its featured image with sizes, and its ACF fields. Typed, precise, one round trip. Pair it with WPGraphQL for ACF and, for the block editor, WPGraphQL Content Blocks to get blocks as structured data rather than HTML.
For anything beyond a simple blog, WPGraphQL. The precision matters at build time when you are fetching hundreds of posts.
Fetching with cache tags
Every fetch to WordPress should be cacheable and taggable so that a publish in WordPress refreshes exactly the pages that changed.
// lib/wp.ts
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
cacheLife('days')
cacheTag('posts', `post:${slug}`)
const res = await fetch(process.env.WP_GRAPHQL_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: POST_QUERY, variables: { slug } }),
})
const { data } = await res.json()
return data.post
}
export async function getAllPostSlugs() {
'use cache'
cacheLife('days')
cacheTag('posts')
// ...
}Two tags per post: the collection tag and the item tag. A single post update revalidates its own page and the listings; a category change revalidates the collection.
Webhooks on publish
WordPress tells Next.js when something changes. A small plugin (or WP Webhooks, or a few lines on save_post and transition_post_status) posts to a Next.js Route Handler with a shared secret and the post's slug and type. The handler calls revalidateTag:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(req: Request) {
const body = await req.json()
if (body.secret !== process.env.REVALIDATE_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
revalidateTag('posts')
if (body.slug) revalidateTag(`post:${body.slug}`)
return Response.json({ revalidated: true })
}Editors press Publish; the page is fresh within seconds; nothing rebuilds.
Preview for drafts
Editors need to see drafts on the real site. The pattern is draft mode: a preview route enables it, the fetch layer asks WordPress for the draft when it is on, and nothing about the draft touches the shared cache.
WordPress needs to serve drafts to an authenticated request. With WPGraphQL, that means a token: WPGraphQL JWT Authentication or an Application Password on a preview user, sent in the Authorization header only when draft mode is enabled. The preview URL is configured in WordPress (a small filter on preview_post_link) to point at the Next.js preview route with the secret and the post ID.
Because draft fetches read draftMode(), they are dynamic and sit outside use cache, which is exactly the isolation you want.
Media
WordPress hosts the images. Next.js's Image needs the WordPress domain in remotePatterns, and either uses its own optimiser against those URLs or, better, WordPress's own generated sizes via srcset data from the API. For large sites, offload WordPress media to object storage with a CDN in front, so the image origin is fast and Next.js's optimiser has something quick to pull from.
Editors need to know that images uploaded in WordPress may take a moment to appear on the site after publish. Revalidation handles it; the delay is seconds.
Content rendering
REST gives you rendered HTML; render it with an HTML-to-React step that maps WordPress's block markup to your components (or sanitised dangerouslySetInnerHTML for simple content). WPGraphQL Content Blocks gives you a tree of blocks; map each block type to a React component. The second approach is more work up front and gives you full control of the output, which is what makes the headless site fast and consistent.
Either way, internal links in content point at the WordPress domain. Rewrite them to the Next.js domain at render time.
The two problems that make teams regret it
Editors lose the preview-as-you-type experience and "View page" goes to the wrong place. The block editor's preview button must open the Next.js preview route, and the site URL in WordPress's settings must not leak into links. Get the preview flow working before editors touch it; a broken preview is the fastest way to lose their trust.
Plugins that render on the front end do nothing. SEO plugins, forms, related posts, comments, membership gates: their front-end output is WordPress-side and the headless front end never runs it. Each needs a headless equivalent (Yoast has a GraphQL extension for metadata; forms need their own solution; comments need an API). List every plugin before committing and know how each one's job will be done on the Next.js side. This list is where headless projects are won or lost.
When it is worth it
A content-heavy site where editors are wedded to WordPress and the business wants Next.js performance and front-end control; or a site that already has a WordPress content base and is getting a Next.js front end for a redesign. Not for a ten-page brochure site (just use one or the other), and not for a store (WooCommerce headless is a much larger undertaking).
It is a build we do regularly as part of Next.js work, and the estimate always starts with the plugin list, because the editor and the fetching are known quantities and the plugins are where the surprises live.