Building Fully Headless WordPress Websites with Next.js and GraphQL in 2025 scaled Headless WordPress

Building Fully Headless WordPress Websites with Next.js and GraphQL in 2025

Last Updated: September 18, 2026By Tags:

A headless WordPress setup keeps WordPress as the content backend (editors keep using wp-admin) while a separate frontend — here, Next.js — renders the site. WordPress and the frontend talk over an API, and WPGraphQL is the standard way to expose that: one GraphQL endpoint at yourdomain.com/graphql instead of the WordPress REST API’s many endpoints, and you query exactly the fields you need instead of getting the REST API’s full (often over-fetched) response objects.

WordPress side: install WPGraphQL

Install and activate the WPGraphQL plugin. That’s it for the basic endpoint — no separate configuration step is required for it to start serving queries at /graphql. If you’re querying Advanced Custom Fields data, also install WPGraphQL for ACF so those fields show up in the GraphQL schema. You can leave your existing theme active; a headless setup doesn’t require deactivating it, though it also won’t be rendering anything the frontend uses.

A real query

Test this directly against your /graphql endpoint (WPGraphQL ships a GraphiQL IDE in wp-admin for exactly this) before wiring it into Next.js:

query GetPosts {
  posts(first: 10) {
    nodes {
      title
      slug
      excerpt
      date
      featuredImage {
        node {
          sourceUrl
          altText
        }
      }
    }
  }
}

Next.js side: fetching in the App Router

If you’re on Next.js’s App Router (the current default, not the older Pages Router), the straightforward approach is fetching directly in a Server Component — no client-side state library needed for read-only content:

// app/blog/page.js
async function getPosts() {
  const res = await fetch('https://your-domain.com/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query: `
        query GetPosts {
          posts(first: 10) {
            nodes { title slug excerpt date }
          }
        }
      `,
    }),
    next: { revalidate: 60 }, // ISR: re-fetch at most once per 60s, not on every request
  });

  const { data } = await res.json();
  return data.posts.nodes;
}

export default async function BlogPage() {
  const posts = await getPosts();
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.slug}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}

The next: { revalidate: 60 } option is Next.js’s Incremental Static Regeneration — the page is served from cache and only re-fetched from WordPress after 60 seconds have passed, so you’re not hitting your WordPress backend on every visitor request. For content that changes rarely, a longer revalidate window (or on-demand revalidation triggered by a WordPress webhook on publish) is more efficient than polling.

If you’re still on the Pages Router, or you need client-side queries (search-as-you-type, authenticated requests), Apollo Client is the standard library for that — install with npm install @apollo/client graphql, point a client instance at your GraphQL endpoint, and use its useQuery hook in client components. It’s more setup than the fetch-in-a-Server-Component approach above, so reach for it only when you actually need client-side state, not by default.

Things that actually trip this up

  • CORS. If your frontend and WordPress are on different domains, WordPress needs to send the right CORS headers for the GraphQL endpoint, or browser-side requests (not an issue for the server-side fetch example above, since that runs on the server, not in the browser) will be blocked.
  • Preview mode. Editors expect to preview a draft before publishing. WPGraphQL supports querying draft/preview content, but it needs authentication (a JWT or application password) since anonymous GraphQL requests only see published content by default — this is usually the part that takes the most setup time in a real project.
  • Stale content after publish. If you’re using a fixed revalidate window rather than on-demand revalidation, there’s a real window where the frontend shows stale content after an editor publishes. For anything time-sensitive, wire up an on-demand revalidation webhook (WordPress’ publish_post hook calling a Next.js revalidation API route) instead of relying purely on a timer.

For SEO on a headless setup, note that your SEO plugin’s (Rank Math, in this site’s case) meta tags and schema output are generated for the traditional WordPress frontend — on a headless site you’re responsible for pulling that same data via GraphQL and rendering the meta tags yourself in Next.js, or using a plugin’s GraphQL-specific SEO fields if it exposes them.

editor's pick

Headless WordPress

news via inbox

Leave A Comment

you might also like