Skip to Content
Headless WordPress

Next.js WordPress Integration Guide for Headless Sites (Next.js 16)

Next.js WordPress Integration Guide for Headless Sites (Next.js 16)

This guide is for developers and technical site owners who know WordPress and want to put a Next.js frontend in front of it, or who are trying to decide whether they should. I build WordPress and WooCommerce sites for clients in the US, UK, EU and Australia, and the questions below come up on almost every headless project: which API to use, how to keep pages fast without serving stale content, how editors preview drafts, and what breaks when WooCommerce is involved.

Last verified: October 2026. The current stable release is Next.js 16.4, and the code targets the App Router with React Server Components.

The short answer

  • Fetch WordPress data in Server Components, so credentials stay on the server.
  • Start with the REST API; move to WPGraphQL when pages combine many related types or ACF fields.
  • Cache WordPress responses for a long time and invalidate by tag when an editor publishes.
  • Build Draft Mode previews before launch, not after.
  • Treat WooCommerce cart and checkout as its own project.
  • For mostly marketing sites, a block theme is often the better choice.

How Next.js caching changed, and why it matters for WordPress

Many tutorials still ranking were written for the Pages Router (getStaticProps) or Next.js 14. The caching rules have moved twice since, and copying an old example is the usual reason a headless site shows stale posts or hits WordPress on every request.

VersionWhat a plain fetch() doesHow you cache WordPress data
Next.js 14Cached by defaultOpt out with cache: 'no-store'
Next.js 15Not cached by defaultOpt in with next: { revalidate, tags } or cache: 'force-cache'
Next.js 16 without Cache ComponentsSame as 15Same as 15
Next.js 16 with Cache ComponentsUncached work must sit inside <Suspense> or be cached'use cache' with cacheLife() and cacheTag()

New apps created with the recommended create-next-app defaults in 16.x have cacheComponents: true switched on. Projects upgraded from 15 usually do not. Check next.config.ts before writing data-fetching code. The Revalidating guide covers the Cache Components model and the previous caching model guide covers fetch options.

Also note that revalidateTag() now takes a second argument; the single-argument form is deprecated. Use revalidateTag('posts', 'max') for stale-while-revalidate, or revalidateTag('posts', { expire: 0 }) when the next visitor must get fresh data.

REST API vs WPGraphQL

Both are production-ready. The REST API ships with WordPress core. WPGraphQL is a free plugin (version 2.x at the time of writing) that is becoming a WordPress.org canonical plugin.

FactorWordPress REST APIWPGraphQL
SetupBuilt inInstall a plugin, endpoint is /graphql
Response shapeFixed per endpoint, trim with _fieldsExactly the fields you request
Related data_embed or extra requestsNested in one query
ACF fields“Show in REST API” per field groupNeeds WPGraphQL for ACF
SEO plugin dataYoast adds fields; Rank Math has an endpointNeeds a community add-on
WooCommerceOfficial Store API and REST APIThird-party extension
DebuggingOpen the URL in a browserGraphiQL IDE in wp-admin

My rule of thumb: if each page needs two or three REST requests, stay on REST. If one page needs posts, authors, categories, ACF repeaters and a menu, use WPGraphQL.

Step 1: Prepare WordPress

  1. Put WordPress on its own hostname, for example cms.example.com, and the Next.js site on www.example.com.
  2. Keep the CMS hostname out of search results with a noindex header. Two indexable copies of the same content is a common reason Google skips pages.
  3. Make WordPress permalinks match your frontend routes, so links in post content, sitemaps and SEO canonicals do not point at the CMS domain.
  4. Create a dedicated user for the frontend with the lowest role that can read drafts (usually Editor) and generate an Application Password for it.

Store the values as server-only environment variables. Never prefix them with NEXT_PUBLIC_, or they end up in the browser bundle.

WORDPRESS_URL=https://cms.example.com
WP_APP_USER=frontend-bot
WP_APP_PASSWORD="abcd efgh ijkl mnop qrst uvwx"
REVALIDATE_SECRET=replace-with-a-long-random-string
PREVIEW_SECRET=replace-with-another-long-random-string

Step 2: Fetch posts from the REST API

This version uses fetch caching options, which is what you want on Next.js 15, or 16 without Cache Components. Each request gets a long revalidate window as a safety net plus tags the webhook can invalidate.

// lib/wordpress.ts
const WP = process.env.WORDPRESS_URL

export async function getPosts() {
  const res = await fetch(
    `${WP}/wp-json/wp/v2/posts?per_page=12&_fields=id,slug,date,title,excerpt`,
    { next: { revalidate: 86400, tags: ['posts'] } }
  )
  if (!res.ok) throw new Error(`WordPress returned ${res.status}`)
  return res.json()
}

export async function getPostBySlug(slug: string) {
  const res = await fetch(
    `${WP}/wp-json/wp/v2/posts?slug=${encodeURIComponent(slug)}`,
    { next: { revalidate: 86400, tags: ['posts', `post-${slug}`] } }
  )
  if (!res.ok) throw new Error(`WordPress returned ${res.status}`)
  const posts = await res.json()
  return posts[0] ?? null // ?slug= always returns an array
}

With Cache Components enabled, wrap the request in a cached function instead. The Next.js docs recommend this pattern for CMS content: a long cacheLife, a tag, and a webhook that calls revalidateTag.

// lib/wordpress-cached.ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getPostBySlug(slug: string) {
  'use cache'
  cacheLife('max')
  cacheTag('posts', `post-${slug}`)

  const res = await fetch(
    `${process.env.WORDPRESS_URL}/wp-json/wp/v2/posts?slug=${encodeURIComponent(slug)}`
  )
  if (!res.ok) throw new Error(`WordPress returned ${res.status}`)
  const posts = await res.json()
  return posts[0] ?? null
}

When you render content.rendered, sanitize it if contributors or plugins can add markup, and rewrite internal links from the CMS hostname to the frontend.

Step 3: The same data with WPGraphQL

GraphQL requests are usually POST, so I cache them in a 'use cache' function rather than relying on fetch options.

// lib/graphql.ts
import { cacheLife, cacheTag } from 'next/cache'

const POST_QUERY = `
  query PostBySlug($slug: ID!) {
    post(id: $slug, idType: SLUG) {
      databaseId
      title
      date
      content
      featuredImage {
        node { sourceUrl altText mediaDetails { width height } }
      }
    }
  }
`

export async function getPostGql(slug: string) {
  'use cache'
  cacheLife('max')
  cacheTag('posts', `post-${slug}`)

  const res = await fetch(`${process.env.WORDPRESS_URL}/graphql`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: POST_QUERY, variables: { slug } }),
  })
  const { data, errors } = await res.json()
  if (errors) throw new Error(errors[0].message)
  return data.post
}

idType: SLUG is a unique lookup only for non-hierarchical types such as posts. For pages, query by URI or DATABASE_ID.

Step 4: On-demand revalidation with a WordPress webhook

Next.js serves the cached page and WordPress tells it when something changes. The route handler checks a shared secret and invalidates the tags it receives:

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  if (request.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
    return Response.json({ message: 'Invalid secret' }, { status: 401 })
  }

  const body = await request.json().catch(() => null)
  const tags: string[] = Array.isArray(body?.tags) ? body.tags : []
  if (tags.length === 0) {
    return Response.json({ message: 'No tags supplied' }, { status: 400 })
  }

  for (const tag of tags) {
    // 'max' serves the old version once while the new one builds.
    // Use { expire: 0 } if the very next load must be fresh.
    revalidateTag(tag, 'max')
  }

  return Response.json({ revalidated: true, tags })
}

On the WordPress side I use wp_after_insert_post rather than save_post. It was added in WordPress 5.6 and fires after terms and meta are saved, so the frontend never refetches a post that is missing its categories or custom fields.

<?php
// wp-content/mu-plugins/nextjs-revalidate.php
add_action( 'wp_after_insert_post', function ( $post_id, $post, $update, $post_before ) {
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }
    if ( 'post' !== $post->post_type ) {
        return;
    }

    $was_public = $post_before && 'publish' === $post_before->post_status;
    $is_public  = 'publish' === $post->post_status;
    if ( ! $was_public && ! $is_public ) {
        return; // Draft-to-draft saves do not affect the live site.
    }

    $tags = array( 'posts', 'post-' . $post->post_name );
    if ( $post_before && $post_before->post_name !== $post->post_name ) {
        $tags[] = 'post-' . $post_before->post_name; // Slug changed.
    }

    wp_remote_post( 'https://www.example.com/api/revalidate', array(
        'headers'  => array(
            'Content-Type'        => 'application/json',
            'x-revalidate-secret' => NEXTJS_REVALIDATE_SECRET,
        ),
        'body'     => wp_json_encode( array( 'tags' => $tags ) ),
        'timeout'  => 5,
        'blocking' => false,
    ) );
}, 10, 4 );

Define NEXTJS_REVALIDATE_SECRET in wp-config.php. Unpublishing or trashing a post is covered because $was_public is true. Add similar calls for menus, options pages and custom post types.

Step 5: Draft previews with Draft Mode

Next.js Draft Mode sets a cookie that bypasses every cache layer for that browser only. First, point the WordPress Preview button at your frontend:

<?php
add_filter( 'preview_post_link', function ( $link, $post ) {
    return add_query_arg( array(
        'secret' => NEXTJS_PREVIEW_SECRET,
        'id'     => $post->ID,
    ), 'https://www.example.com/api/draft' );
}, 10, 2 );

Then enable Draft Mode in a route handler. draftMode() has been async since Next.js 15.

// app/api/draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const id = Number(searchParams.get('id'))

  if (searchParams.get('secret') !== process.env.PREVIEW_SECRET || !id) {
    return new Response('Invalid preview request', { status: 401 })
  }

  const draft = await draftMode()
  draft.enable()
  redirect(`/preview/${id}`)
}

The /preview/[id] page fetches /wp-json/wp/v2/posts/{id} with an Authorization: Basic header built from the Application Password, because drafts are not public. Unsaved edits to an already-published post live at /wp-json/wp/v2/posts/{id}/autosaves. On WPGraphQL, the docs now recommend an authenticated request with an X-GraphQL-Preview header; the older asPreview argument is deprecated. See the WPGraphQL previews documentation for the header format.

Authentication options

The WordPress REST API authentication handbook gives you three practical routes:

MethodGood forWatch out for
Application Passwords (core since 5.6)Server-to-server reads of drafts and private contentBasic auth, so HTTPS only; keep it on the server
JWT (via plugin)Visitors who log in, member areas, appsAnother plugin to maintain; you own token expiry
Cookie and nonceCode running inside wp-adminDoes not work across domains for a headless frontend

For most content sites, one Application Password used only in server code is enough.

Images

next/image needs the WordPress uploads host in remotePatterns. In Next.js 16, images.qualities is required, and the priority prop is deprecated in favour of preload.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [new URL('https://cms.example.com/wp-content/uploads/**')],
    qualities: [75],
  },
}

export default nextConfig

Pass the width and height WordPress stores (media_details in REST, mediaDetails in GraphQL) to avoid layout shift. If your host or an image CDN already serves resized WebP or AVIF, skip Next.js optimisation for those images rather than paying for the work twice.

SEO metadata from Yoast or Rank Math

Let editors keep their SEO plugin and read its output:

  • Yoast SEO adds yoast_head and yoast_head_json (Yoast 16.7 and later) to REST responses, plus a /wp-json/yoast/v1/get_head?url= endpoint. See the Yoast REST API docs.
  • Rank Math needs “Headless CMS Support” enabled under General Settings, Others. It then exposes /wp-json/rankmath/v1/getHead?url=, which returns the head as HTML for you to parse.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPostBySlug } from '@/lib/wordpress'

export async function generateMetadata(
  { params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
  const { slug } = await params
  const seo = (await getPostBySlug(slug))?.yoast_head_json
  if (!seo) return {}

  return {
    title: seo.title,
    description: seo.description,
    alternates: { canonical: `https://www.example.com/blog/${slug}/` },
    openGraph: {
      title: seo.og_title,
      description: seo.og_description,
      images: seo.og_image?.map((img: { url: string }) => img.url),
    },
  }
}

I build the canonical from the frontend URL on purpose. If WordPress still treats its own address as the site URL, the plugin canonical points at the CMS hostname and tells Google to index the wrong domain. Generate the sitemap in app/sitemap.ts from the same data.

Forms

There is no theme, so form plugins cannot render their markup. Three options, simplest first:

  1. A Server Action that validates input, checks a spam token (Turnstile or reCAPTCHA) and sends email through a transactional provider.
  2. A Server Action that posts to a form plugin’s REST API, such as Gravity Forms, so entries still appear in wp-admin.
  3. A small custom WordPress REST endpoint that stores the entry and triggers existing notifications.

For UK and EU visitors, GDPR basics still apply: a privacy notice by the form, only the fields you need, and knowing where submissions are processed and stored. Cookie consent becomes your job too. WordPress consent plugins inject their banner into the theme, so on a headless site the banner and the logic that holds analytics until consent must live in Next.js.

Headless WooCommerce caveats

Product and category pages work well headless. Cart, checkout and accounts are where projects overrun.

  • Use the Store API for the cart at /wp-json/wc/store/v1/. Cart responses return a Cart-Token header; send it back on later cart and checkout requests and no nonce is needed. See the Cart Tokens documentation.
  • Never cache anything per shopper. Carts, customer-specific prices and account pages stay out of 'use cache' and tagged fetches. Product listings can be cached and revalidated when products are saved.
  • Payment gateways are the real constraint. Before promising a custom checkout, confirm every gateway, shipping, tax and subscription plugin supports the Store API.
  • A hybrid often wins. A headless catalogue and cart that hands over to the WooCommerce checkout keeps every payment plugin working, at the cost of some visual consistency.
  • Keep REST API keys server-side. WooCommerce /wc/v3/ consumer keys carry broad permissions.

If the store relies on custom pricing, booking or marketplace logic, that logic needs to be exposed over the Store API, which is the kind of work I do through WooCommerce plugin development.

Hosting and regions

You now run two systems: WordPress (PHP and MySQL) and Next.js (Node.js).

Frontend hostNotes
VercelFirst-party support for Draft Mode, tag revalidation and Cache Components
Netlify, Cloudflare and othersThrough adapters; Next.js 16.2 added a stable Adapter API. Test revalidation and previews before committing
Self-hosted Node (Docker on a VPS)Full control; you configure the cache handler and CDN yourself

The setting most people miss is the function region. Vercel runs functions in Washington, D.C. (iad1) by default for new projects. Cached pages come from the CDN location nearest each visitor anyway, but every cache miss, preview and revalidation is a round trip from the function to WordPress. If WordPress is hosted in Frankfurt or London for a European client, move functions next to it (for example fra1 or lhr1) using Vercel’s region settings. The same applies to an Australian client with WordPress in Sydney. For EU data residency, keeping WordPress and the functions that handle form data in the EU also simplifies the privacy conversation.

Budget for patching both sides: Next.js has shipped several security releases in 2026, and WordPress plugins still need updates. I handle the WordPress side through WordPress maintenance.

When headless is not worth it

This is the table I walk clients through:

QuestionClassic themeBlock themeHeadless Next.js
Editors build layouts themselvesDepends on builderYes, in the Site EditorOnly blocks mapped to React
Preview works out of the boxYesYesOnly after you build it
Plugins with frontend output (forms, popups, LMS, memberships)WorkMostly workRebuilt or replaced
Performance ceilingGood with cachingGoodHighest, if built carefully
Systems to host and patchOneOneTwo
Skills neededPHP, WordPressPHP, block developmentReact, Next.js and WordPress
Best fitExisting sites, tight budgetsNew marketing and content sitesApps, multiple frontends, React teams

Stay with a theme when the site is mostly marketing pages, when editors depend on page builders or frontend plugins, when nobody will own a Node.js deployment, or when the real problem is speed that hosting and caching would fix. Theme customization or a block theme gets most of the benefit for much less. Headless earns its cost when the same content feeds a site and an app, when the frontend needs interactions a theme makes awkward, or when a React team will maintain it.

Frequently asked questions

Is WPGraphQL better than the REST API for Next.js?

Neither is better in general. The REST API is built in and easy to debug, which suits blogs and simple sites. WPGraphQL fits pages that pull many related objects and custom fields at once.

Does Next.js 16 cache WordPress fetch requests by default?

No. Since Next.js 15, a plain fetch() is not cached. On the older model you opt in with next: { revalidate, tags }; with Cache Components you use 'use cache', cacheLife() and cacheTag().

How do I update a Next.js page as soon as a WordPress post is published?

Tag your WordPress requests, add a route handler that calls revalidateTag(), and call it from the wp_after_insert_post hook with a shared secret. Use 'max' for normal content or { expire: 0 } when the next visitor must see the change.

Can editors still preview drafts on a headless WordPress site?

Yes, once you build it. Filter preview_post_link so Preview opens a Next.js route, enable Draft Mode there, and fetch the draft with an Application Password. Only that editor’s browser bypasses the cache.

Do Yoast SEO and Rank Math work with headless WordPress?

Yes. Yoast adds yoast_head_json to REST responses and Rank Math offers a getHead endpoint once headless support is on. Make sure canonicals point to the frontend domain and the CMS hostname is not indexed.

Can WooCommerce run fully headless with Next.js?

The catalogue and cart can, using the Store API and its Cart-Token header. A fully custom checkout depends on every payment, shipping and subscription plugin supporting the Store API, so many stores keep the WooCommerce checkout.

Where should I host the frontend for UK, EU or Australian clients?

Any host that supports the Next.js features you use. Put server functions in the same region as WordPress, because cache misses and previews travel between them.

Is headless WordPress better for SEO?

Not by itself. Headless can improve Core Web Vitals, but it adds new ways to fail, such as an indexable CMS domain, wrong canonicals and missing sitemaps. The result depends on the build.


If you are weighing a Next.js frontend for an existing WordPress or WooCommerce site and want a second opinion on whether it will pay off, see my headless WordPress development service. If a theme would serve you better, I will say so.

Leave a Reply