rsz_headless_wordpress_site_1 (1)

How to Build a Headless WordPress Site with WPGraphQL and Next.js (2025 Guide)

Headless WordPress is transforming how developers build web experiences. By decoupling the frontend and backend, it offers superior flexibility, speed, and control. This guide walks you through creating a headless WordPress site using WPGraphQL and Next.js in 2025, helping you harness the full power of a modern web stack.

What is Headless WordPress?

In a traditional WordPress setup, your themes and plugins render the frontend through PHP. A headless approach decouples this entirely. WordPress acts only as a content management backend, while a separate frontend—like one built with React, Vue, or in our case, Next.js—renders the content.

Benefits:

  • Performance: Static site generation (SSG) improves loading speed and scalability.
  • Security: The public site doesn’t expose WordPress admin endpoints.
  • Scalability: Easily integrate with microservices and APIs.
  • Flexibility: Use modern tools and frameworks for UX.

Why WPGraphQL and Next.js?

WPGraphQL is a free plugin that adds a GraphQL server to WordPress, allowing structured data fetching.

Next.js is a React framework supporting SSR and SSG, essential for SEO and performance.

Together, they:

  • Enable cleaner, faster API queries.
  • Support rendering content statically or dynamically.
  • Allow developers to use modern frontend architectures.

Prerequisites

  • A local WordPress site (via LocalWP or similar).
  • Node.js and npm installed.
  • Basic knowledge of React and GraphQL.

Step 1: Set Up WordPress Backend

  1. Install WordPress locally.
  2. Go to Settings > Permalinks and set to “Post name”.
  3. Install and activate these plugins:
  4. Create some posts, pages, and optionally custom post types.

Step 2: Create Next.js App

  1. Initialize a new app:
npx create-next-app headless-wp
cd headless-wp
  1. Install dependencies:
npm install @apollo/client graphql
  1. Create .env.local and add:
NEXT_PUBLIC_WORDPRESS_API_URL=http://localhost:10003/graphql

Step 3: Set Up Apollo Client

Create a new file: lib/apollo.js

import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: process.env.NEXT_PUBLIC_WORDPRESS_API_URL,
  cache: new InMemoryCache()
});

export default client;

Step 4: Query Posts from WordPress

Create GraphQL query: lib/queries.js

import { gql } from '@apollo/client';

export const GET_POSTS = gql`
  query GetPosts {
    posts {
      nodes {
        id
        title
        slug
        excerpt
      }
    }
  }
`;

Create pages/index.js:

import client from '../lib/apollo';
import { GET_POSTS } from '../lib/queries';

export default function Home({ posts }) {
  return (
    <div>
      <h1>Recent Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <a href={`/posts/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

export async function getStaticProps() {
  const { data } = await client.query({ query: GET_POSTS });
  return {
    props: { posts: data.posts.nodes },
    revalidate: 10
  };
}

Step 5: Create Dynamic Post Pages

  1. Create pages/posts/[slug].js
import client from '../../lib/apollo';
import { gql } from '@apollo/client';

const GET_POST = gql`
  query GetPost($slug: ID!) {
    post(id: $slug, idType: SLUG) {
      title
      content
    }
  }
`;

export default function Post({ post }) {
  return (
    <div>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </div>
  );
}

export async function getStaticPaths() {
  const { data } = await client.query({
    query: gql`
      query AllSlugs {
        posts {
          nodes {
            slug
          }
        }
      }
    `
  });

  const paths = data.posts.nodes.map(post => ({ params: { slug: post.slug } }));

  return {
    paths,
    fallback: true
  };
}

export async function getStaticProps({ params }) {
  const { data } = await client.query({
    query: GET_POST,
    variables: { slug: params.slug }
  });

  return {
    props: {
      post: data.post
    },
    revalidate: 10
  };
}

Step 6: Add SEO Tags

Use next/head in each page:

import Head from 'next/head';

<Head>
  <title>{post.title}</title>
  <meta name="description" content={post.excerpt} />
</Head>

Step 7: Deploy to Vercel

  1. Push to GitHub.
  2. Go to Vercel and import the repo.
  3. Set your environment variable: NEXT_PUBLIC_WORDPRESS_API_URL
  4. Click deploy.

Optimization Tips

  • Use Incremental Static Regeneration (ISR).
  • Use CDN caching (Vercel does this automatically).
  • Enable image optimization with next/image.
  • Monitor performance with Google PageSpeed Insights.

Internal Resources

External Resources

Conclusion

Building a headless WordPress site using WPGraphQL and Next.js empowers developers with flexibility, performance, and scalability. As more businesses demand lightning-fast experiences and modern UIs, this architecture becomes increasingly relevant. Follow this 2025 guide to create future-proof, maintainable, and high-performing WordPress sites.

Fix Apollo single post queries in Next.js

When a list of posts loads but an individual post does not, check the GraphQL query, slug value, and response before changing the frontend. The single-post query must request a post by its slug and pass the same slug received by the dynamic route.

For Apollo Client, keep the query and variable names consistent:

const SINGLE_POST_QUERY = gql` query SinglePost($slug: ID!) { post(id: $slug, idType: SLUG) { title slug excerpt content } } `;

Then call it with the route parameter:

client.query({ query: SINGLE_POST_QUERY, variables: { slug: params.slug } })

  • Confirm the slug exists. The value in params.slug must match the WordPress post slug, not the post title or numeric database ID.
  • Check the GraphQL response. If data.post is null, the post may be unpublished, the slug may be incorrect, or the WordPress API URL may point to a different site.
  • Handle missing content. With fallback: true, the page can render before the post is generated. Use Next.js fallback handling, or use fallback: 'blocking' when supported by your routing setup so the first request waits for the page data.
  • Protect the render. Do not access post.title until a post exists. Return a not-found result for an invalid slug when appropriate.

If the query works in a GraphQL client but fails in Next.js, verify that NEXT_PUBLIC_WORDPRESS_API_URL is set in the deployment environment and that it ends with the correct /graphql path. After changing an environment variable, redeploy the frontend. Also make sure the WordPress site is reachable from the hosting provider; a local URL such as http://localhost:10003/graphql works only from the local development environment.