Building Fully Headless WordPress Websites with Next.js and GraphQL in 2025
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
revalidatewindow 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_posthook 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.


