How-To GuidesHeadless WordPress GraphQL

Headless WordPress + GraphQL

Launch a headless WordPress stack that feeds this site through WPGraphQL, Apollo Client, and the pagination helpers we reuse across our blog examples.

When to use

  • You need marketing or editorial teams to keep using WordPress while shipping the front end in Next.js.
  • You want GraphQL schemas with type safety and control rather than REST payloads.
  • You have content-heavy sections (blog, resources, changelog) that rely on cursor-based pagination.

Checklist

  • WordPress instance with WPGraphQL enabled and public schema accessible.
  • Authentication strategy defined (JWT or Application Passwords) and tokens scoped to queries/mutations we need.
  • Environment variables configured locally and in deployment targets for endpoint, auth, and preview secrets.
  • Apollo Client configured in Next.js with persistent caching and pagination helpers.
  • Pagination fragments implemented so list pages reuse the same cursor logic as the blog starter.

Step-by-step

1. Prepare WordPress

  1. Install the WPGraphQL plugin.
  2. If you need preview or private content, also install WPGraphQL JWT Authentication or enable Application Passwords (WordPress 5.6+).
  3. Under GraphQL > Settings, make sure “Public Introspection” is enabled so the schema can be fetched during build-time type generation.
  4. Create at least one user with the appropriate capabilities (Editor for content management, Administrator for schema changes).

2. Configure authentication

  1. For JWT: generate a secret key in wp-config.php (define( 'GRAPHQL_JWT_AUTH_SECRET_KEY', 'super-secret' );).
  2. For Application Passwords: generate a password for an API-only user and note the base64 encoded user:password string.
  3. Decide which flows will require auth headers: drafts, preview, or mutations (comments, reactions).
  4. Document the header shape (Authorization: Bearer <token> for JWT, Authorization: Basic <base64> for App Passwords).

3. Define environment variables

Add the following entries to .env.local and to your deployment provider:

NEXT_PUBLIC_WORDPRESS_URL=https://your-site.com
WORDPRESS_GRAPHQL_ENDPOINT=https://your-site.com/graphql
WORDPRESS_AUTH_HEADER="Authorization=Bearer <token>"
WORDPRESS_PREVIEW_SECRET=<random-string>

Use NEXT_PUBLIC_WORDPRESS_URL anywhere the front end needs to link back to the canonical WordPress URL. Keep the private auth header and preview secret out of the NEXT_PUBLIC_* namespace.

4. Configure Apollo Client

  1. Create a singleton Apollo client (for example in lib/apollo.js) that reads WORDPRESS_GRAPHQL_ENDPOINT and WORDPRESS_AUTH_HEADER from process.env.
  2. Enable InMemoryCache with typePolicies for RootQuery connections (merge uses cursor uniqueness) and for any custom post type connections.
  3. Reuse the pagination helpers from the blog example (relayStylePagination from @apollo/client/utilities). Pair it with fragments that spread pageInfo { hasNextPage endCursor } so the UI can request more items.
  4. Wrap your Next.js App with ApolloProvider and ensure SSR mode is enabled for static pages (ssrMode: typeof window === 'undefined').

5. Hook up pagination patterns

  1. Model connection queries with variables { first, after } (forward pagination) or { last, before } (reverse) so they match the blog components.
  2. Expose helper hooks, e.g. usePostsConnection that wraps Apollo useQuery and returns { nodes, pageInfo, fetchMore }.
  3. In UI components, pass the endCursor into fetchMore({ variables: { after: pageInfo.endCursor } }) and merge results in the cache policy instead of manually concatenating arrays.
  4. For static generation, prefetch the first page in getStaticProps, then call fetchMore on the client when the reader clicks “Load more” just like the blog listing.

Gotchas

  • Disable GraphQL introspection in production only if your build step already fetched schema metadata; otherwise type generation will fail.
  • JWT plugins often require HTTPS to issue tokens; if using local tunnels, trust the certificate or test with Application Passwords instead.
  • When revalidating ISR pages, ensure the preview secret matches the WORDPRESS_PREVIEW_SECRET otherwise /api/revalidate handlers will return 401.
  • Apollo cache keys must include __typename and id; missing IDs in custom post types require you to set a keyFields override.
  • WordPress stores draft content in revisions. If your GraphQL viewer lacks permission, preview queries will return null nodes, so guard components accordingly.

Connection Code

npm install @apollo/client graphql
import { ApolloClient, InMemoryCache } from '@apollo/client';
 
const client = new ApolloClient({
   uri: `${process.env.NEXT_PUBLIC_WORDPRESS_URL}/?graphql`,
   cache: new InMemoryCache(),
   headers: {
      authorization: 'Basic ' + process.env.NEXT_PUBLIC_WORDPRESS_AUTH_TOKEN,
   },
   defaultOptions: {
      watchQuery: {
         fetchPolicy: 'no-cache',
      },
      query: {
         fetchPolicy: 'no-cache',
      },
      mutate: {
         fetchPolicy: 'no-cache',
      },
   },
});
 
export default client;

How To use

Basic Query Example

import { gql } from '@apollo/client';
 
export const Post = gql`
   query post($slug: String) {
      postBy(slug: $slug) {
         id
         title
         date
         slug
         uri
         author {
            node {
               name
               username
               avatar {
                  url
               }
            }
         }
         categories {
            nodes {
               categoryId
               name
               uri
            }
         }
         content(format: RENDERED)
      }
   }
`;

Using the Query in a Component

// pages/post/[slug].js
import { useQuery } from '@apollo/client';
import { Post } from '../../queries/Post';
 
export default function PostPage({ slug }) {
  const { data, loading, error } = useQuery(Post, {
    variables: { slug }
  });
 
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data?.postBy) return <div>Post not found</div>;
 
  const post = data.postBy;
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      <div>
        <p>By {post.author.node.name}</p>
        <p>Published: {post.date}</p>
        {post.categories.nodes.map(category => (
          <span key={category.categoryId}>{category.name}</span>
        ))}
      </div>
    </article>
  );
}