A Next.js frontend does not automatically make WordPress faster or safer. It changes who renders the page and who is responsible when something breaks.

In a headless setup, editors publish in WordPress, WPGraphQL exposes content through an API, and Next.js renders the public site. That division can be useful when a team wants a React frontend without replacing its publishing workflow. It also gives you two systems to deploy, monitor, secure, and keep in sync.

This walkthrough builds a deliberately small blog: an index of published posts and a detail page at /posts/[slug]. The example targets Next.js 15 with the App Router, a WordPress installation with WPGraphQL, and public posts only. It starts with uncached server requests so content behavior is easy to inspect. Caching, drafts, and webhook invalidation come after the basic path works.

Check the data boundary before writing React

The flow is:

WordPress editor -> WordPress database -> WPGraphQL endpoint -> Next.js server -> rendered HTML -> visitor

Visitors talk to Next.js, but WordPress is not necessarily private. The GraphQL endpoint, WordPress login, and media files may still be publicly reachable. Headless architecture does not replace WordPress updates, authorization, request limits, backups, or monitoring. WPGraphQL describes its authorization model separately from the frontend.

GraphQL is an option, not a prerequisite for headless WordPress. WordPress also ships a REST API. GraphQL is helpful here because the frontend can request named fields and reuse typed query shapes. It does not remove the cost of resolvers, network latency, or poorly designed queries.

If the site needs structured content beyond standard posts, the ACF vs. custom post types guide covers the content-model decision. Exposing ACF fields through GraphQL also requires WPGraphQL for ACF and explicit schema configuration.

Prepare a WordPress post you can query

On a WordPress installation you control (WPGraphQL currently requires WordPress 6.0 or newer, per its compatibility guide):

  1. Install and activate the WPGraphQL plugin. With WP-CLI, run wp plugin install wp-graphql --activate.
  2. In Settings > Permalinks, select a non-Plain structure if you want the usual /graphql endpoint. With Plain permalinks, the endpoint can instead be /index.php?graphql.
  3. Publish one ordinary post with a unique slug, for example hello-headless. Leave a second post as a draft so you can verify that the public query does not reveal it.
  4. Open GraphiQL in the WordPress admin and run:
query RecentPosts {
  posts(first: 5) {
    nodes {
      slug
      title
    }
  }
}

The same public query can be checked without the admin UI:

curl --fail-with-body -X POST 'https://cms.example.com/graphql' \
  -H 'Content-Type: application/json' \
  --data '{"query":"query { posts(first: 5) { nodes { slug title } } }"}'

Replace the example host with your own endpoint. You should see a data.posts.nodes array containing published posts. This is a verification step, not an observed result from a live CMS used for this article. WPGraphQL documents both its POST request format and queries for posts by slug.

Create the Next.js application

Start a separate project using the Next.js 15 CLI. Choose the App Router, TypeScript, and no src/ directory so the following paths match. Tailwind is optional.

npx create-next-app@15 headless-blog
cd headless-blog
npm install sanitize-html
npm install -D @types/sanitize-html

Put the endpoint in .env.local. The variable deliberately has no NEXT_PUBLIC_ prefix, because the fetch will run on the server:

WORDPRESS_GRAPHQL_URL=https://cms.example.com/graphql

A local CMS may use an HTTP URL, but use HTTPS for a deployed CMS. Do not put WordPress credentials in a browser-exposed environment variable.

Make GraphQL failure states explicit

GraphQL has two distinct ways to fail. The HTTP request can return an error status, or it can return a successful HTTP status with an errors array in the JSON response. A missing post is different again: a valid query may return { "post": null }.

Create lib/wpgraphql.ts:

type GraphQLError = { message: string }

type GraphQLResult<T> = {
  data?: T | null
  errors?: GraphQLError[]
}

export async function wpQuery<T>(
  query: string,
  variables: Record<string, unknown> = {}
): Promise<T> {
  const endpoint = process.env.WORDPRESS_GRAPHQL_URL

  if (!endpoint) {
    throw new Error('WORDPRESS_GRAPHQL_URL is missing')
  }

  const response = await fetch(endpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
    cache: 'no-store',
  })

  if (!response.ok) {
    throw new Error('WPGraphQL HTTP ' + response.status)
  }

  let result: GraphQLResult<T>
  try {
    result = (await response.json()) as GraphQLResult<T>
  } catch {
    throw new Error('WPGraphQL returned invalid JSON')
  }

  if (result?.errors?.length) {
    throw new Error(
      'WPGraphQL: ' + result.errors.map(error => error.message).join('; ')
    )
  }

  if (!result?.data) {
    throw new Error('WPGraphQL returned no data')
  }

  return result.data
}

This helper does not accept arbitrary queries from the browser. Query documents are defined in server-side code. Variables are sent as JSON rather than interpolated into a GraphQL string, so a slug containing quotes cannot alter the query document.

The explicit cache: 'no-store' is important. Next.js 15 does not enable the Data Cache for every fetch by default, and a page can still be prerendered depending on its data dependencies. This example opts out intentionally, rather than assuming that a comment about ISR guarantees fresh or static output. See the Next.js 15 caching guide.

Query the list and a single post

Create lib/posts.ts. The data types here describe the fields this application actually requests; they are not a generated representation of the entire WPGraphQL schema.

import { cache } from 'react'
import { wpQuery } from './wpgraphql'

type PostSummary = {
  slug: string
  title: string | null
}

export type Post = PostSummary & {
  excerpt: string | null
  content: string | null
}

type ListResponse = {
  posts: { nodes: PostSummary[] }
}

type DetailResponse = {
  post: Post | null
}

export async function getPosts(): Promise<PostSummary[]> {
  const data = await wpQuery<ListResponse>(`
    query RecentPosts {
      posts(first: 20) {
        nodes { slug title }
      }
    }
  `)

  return data.posts.nodes
}

export const getPost = cache(async (slug: string): Promise<Post | null> => {
  const data = await wpQuery<DetailResponse>(
    `
      query PostBySlug($slug: ID!) {
        post(id: $slug, idType: SLUG) {
          slug
          title
          excerpt
          content
        }
      }
    `,
    { slug }
  )

  return data.post
})

The cache function from React deduplicates the same getPost(slug) call within a server render. That matters because the page and its metadata both need the post. Automatic React fetch memoization applies to GET/HEAD requests, not this GraphQL POST request; Next.js recommends cache for this case. This is request-level memoization, not a cross-request freshness policy.

The list is intentionally limited to 20 posts. That is enough for this example, not a complete archive. A real blog needs cursor pagination when its content exceeds that limit.

Render the index

Create app/page.tsx:

import Link from 'next/link'
import { getPosts } from '../lib/posts'

export default async function Home() {
  const posts = await getPosts()

  return (
    <main>
      <h1>Latest posts</h1>
      {posts.length === 0 ? <p>No published posts yet.</p> : null}
      <ul>
        {posts.map(post => (
          <li key={post.slug}>
            <Link href={'/posts/' + post.slug}>
              {post.title || 'Untitled post'}
            </Link>
          </li>
        ))}
      </ul>
    </main>
  )
}

This is a Server Component, so the CMS URL and query logic stay on the server. For a closer look at that boundary, see the React Server Components guide.

Render the detail route and metadata

Create app/posts/[slug]/page.tsx:

import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import sanitizeHtml from 'sanitize-html'
import { getPost } from '../../../lib/posts'

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) {
    return { title: 'Post not found', robots: { index: false } }
  }

  const description = sanitizeHtml(post.excerpt ?? '', {
    allowedTags: [],
    allowedAttributes: {},
  }).trim()

  return {
    title: post.title || 'Untitled post',
    description: description || undefined,
  }
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)

  if (!post) notFound()

  const safeContent = sanitizeHtml(post.content ?? '')

  return (
    <main>
      <article>
        <h1>{post.title || 'Untitled post'}</h1>
        <div dangerouslySetInnerHTML={{ __html: safeContent }} />
      </article>
    </main>
  )
}

There are three deliberate decisions here:

  • params is awaited. Dynamic route parameters are a Promise in Next.js 15, including in generateMetadata. A synchronous params.slug example is outdated for this target.
  • A nonexistent post calls notFound(). A missing article should not quietly render a blank page. Check the response status in a production build; Next.js documents a distinction between streamed and non-streamed not-found responses.
  • WordPress HTML crosses a trust boundary. WPGraphQL returns rendered block content as HTML. React does not sanitize dangerouslySetInnerHTML. The example uses sanitize-html with its default allowlist to show an explicit boundary. Those defaults can strip images, embeds, custom block attributes, and presentation markup. A real site must define and test an allowlist for its permitted blocks, URLs, and editor capabilities, not simply disable sanitization to make an embed work.

For this first version, no generateStaticParams is needed: the pages are rendered on request. If you later prerender a subset of posts, generateStaticParams only covers the slugs returned at build time; it is not rerun during ISR. Also decide what happens to new slugs that were not in the build-time list.

Debug the request, not just the React component

When the page is blank or stale, locate the failed boundary before changing frontend state:

What you observeLikely boundaryWhat to check
HTTP 404 or HTML instead of JSONWordPress endpointPermalink mode, configured path, proxy rules, plugin activation
HTTP 200 with GraphQL errorsSchema or resolverExact error message, queried fields, plugin versions, authorization
post: null with a valid responseContent lookupSlug, publication status, WordPress visibility rules
An old title after an editCachingNext.js Data/Full Route caches, hosting cache, WordPress cache
Missing styles or embedsHTML representationSanitizer allowlist, Gutenberg blocks, stylesheets, media URLs

A post: null result is not proof that the CMS is down. It can mean the slug does not exist or the requesting identity cannot see that content. WPGraphQL's authorization documentation explains why drafts require more than an anonymous query.

A useful local smoke test is to visit a valid slug, an invented slug, and then temporarily point WORDPRESS_GRAPHQL_URL to an invalid path. The expected categories are: rendered article, not-found response, and a server-side API error. The exact HTTP response, error UI, and logs must be checked against a running Next.js build.

Add caching only after choosing a freshness contract

The helper currently uses cache: 'no-store', which favors immediate source reads over serving a cached CMS response. That is easy to reason about, but every fresh request depends on WordPress availability and latency.

For public content with an acceptable delay before edits appear, replace that single fetch option with:

next: { revalidate: 60 },

That configures a time-based Data Cache revalidation interval in the Next.js 15 caching model. It is not a promise that an editor's change appears exactly 60 seconds after clicking Publish. Check the Full Route Cache, upstream caches, and what happens during a CMS outage. Next.js documents revalidation behavior.

If editors need faster updates, a server-to-server WordPress webhook can call a protected Next.js Route Handler that invokes revalidatePath('/posts/' + slug) and invalidates the index. revalidatePath marks a route for revalidation on a subsequent visit when called from a Route Handler. Validate the webhook secret, payload and allowed paths, and decide how deletes, slug changes, and retries behave. WordPress does not automatically provide that secure integration just because WPGraphQL is installed.

Do not reuse this public cached query for previewing drafts. Draft Mode requires a separately authenticated data path and a server-side authorization check. Keep credentials out of client bundles, and do not put private responses into a shared public cache. Next.js Draft Mode and WPGraphQL authentication cover the pieces, but connecting them is a separate implementation.

What to verify before putting this online

Run the example against a WordPress instance you control, using the version and hosting configuration you intend to deploy:

  1. npm run build and npm run start must succeed. Check /, a published /posts/hello-headless, and a nonexistent slug.
  2. Verify that the anonymous GraphQL request cannot read the unpublished test post. Never infer authorization from a hidden link.
  3. Edit a published post and observe the result with no-store, then with the chosen caching policy. Record actual timestamps if you want to make a freshness claim.
  4. Try headings, links, images, and any custom Gutenberg blocks your editors use. Inspect the sanitized output and accessible image alternatives.
  5. Check rendered titles, descriptions, canonical URLs, redirects, media requests, and any SEO data previously supplied by a WordPress theme. Those do not automatically migrate to Next.js.
  6. Limit schema exposure and request complexity where appropriate, update WordPress and plugins, and test behavior when the CMS is unavailable.

The code is a small implementation example, not a production deployment report. No live WordPress endpoint, end-to-end Next.js build, or production performance test is claimed here.

Headless WordPress is worth considering when a team needs WordPress editorial workflows alongside a separately maintained React application. If a conventional WordPress theme already meets your interaction, deployment, and maintenance needs, adding a GraphQL server boundary and another frontend may be unnecessary complexity. The decision should follow your content model, failure budget, editing workflow, and ability to operate both systems, not a blanket promise of speed.