Home Blog

GraphQL for Headless WordPress in Practice: From First Query to Production

Step 1: The First Query — a Paginated Post List WPGraphQL uses cursor pagination: ask for first N nodes, then pass endCursor back as after for the next page. Cursors are opaque…

Introduction

The GraphQL overview covered why WPGraphQL is attractive for headless WordPress and where it bites. This piece is the practical half: one small build, step by step, from the first query to a setup you can put in production.

What we build: a blog frontend on Next.js (App Router) that reads posts from WordPress through WPGraphQL. A paginated list, a single post page, one custom field, shared fragments, production limits, and cache revalidation when an editor publishes.

The Setup

  • WordPress with WPGraphQL installed. The endpoint is https://cms.example.com/graphql.
  • WPGraphQL for ACF only if you expose ACF fields. Not needed for this walkthrough.
  • WPGraphQL Smart Cache for persisted queries and cache invalidation (step 5 and 6).
  • Next.js with the App Router. No GraphQL client library: server components and fetch are enough.

One helper does all requests. It sends queries as GET, which matters later for caching:

Javascript
// lib/wp.ts
const WP_GRAPHQL_URL = process.env.WP_GRAPHQL_URL!;

export async function wpQuery<T>(
  query: string,
  variables: Record<string, unknown> = {},
  tags: string[] = []
): Promise<T> {
  const url = new URL(WP_GRAPHQL_URL);
  url.searchParams.set('query', query);
  url.searchParams.set('variables', JSON.stringify(variables));

  const res = await fetch(url, { next: { revalidate: 300, tags } });
  if (!res.ok) throw new Error(`WPGraphQL responded ${res.status}`);

  const json = await res.json();
  if (json.errors?.length) throw new Error(json.errors[0].message);
  return json.data as T;
}

Step 1: The First Query — a Paginated Post List

WPGraphQL uses cursor pagination: ask for first N nodes, then pass endCursor back as after for the next page.

Plaintext
query Posts($first: Int!, $after: String) {
  posts(first: $first, after: $after) {
    pageInfo {
      hasNextPage
      endCursor
    }
    nodes {
      slug
      title
      date
      excerpt
    }
  }
}
Javascript
// app/blog/page.tsx
import Link from 'next/link';
import { wpQuery } from '@/lib/wp';
import { POSTS_QUERY } from '@/lib/queries';

export default async function BlogPage({
  searchParams,
}: {
  searchParams: Promise<{ after?: string }>;
}) {
  const { after } = await searchParams;
  const { posts } = await wpQuery<PostsData>(
    POSTS_QUERY,
    { first: 10, after: after ?? null },
    ['posts']
  );

  return (
    <main>
      {posts.nodes.map((post) => (
        <article key={post.slug}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </article>
      ))}
      {posts.pageInfo.hasNextPage && (
        <Link href={`/blog?after=${encodeURIComponent(posts.pageInfo.endCursor)}`}>
          Older posts
        </Link>
      )}
    </main>
  );
}

Cursors are opaque strings. Don’t build page numbers from them; “Older posts / Newer posts” is the natural UI for cursor pagination.

Step 2: The Single Post Page

One query returns the post, its author, categories, and featured image. In REST this would be several calls or a heavy _embed.

Plaintext
query PostBySlug($slug: ID!) {
  post(id: $slug, idType: SLUG) {
    title
    date
    content
    author {
      node {
        name
      }
    }
    categories {
      nodes {
        name
        slug
      }
    }
    featuredImage {
      node {
        sourceUrl
        altText
        mediaDetails {
          width
          height
        }
      }
    }
  }
}
Javascript
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { wpQuery } from '@/lib/wp';
import { POST_BY_SLUG_QUERY } from '@/lib/queries';

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const { post } = await wpQuery<PostData>(POST_BY_SLUG_QUERY, { slug }, [`post:${slug}`]);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

content is rendered HTML from WordPress. That’s fine when WordPress is the only trusted author of that HTML. If you need to render blocks as your own components, look at WPGraphQL Content Blocks, which exposes Gutenberg blocks as structured data.

Step 3: A Custom Field in the Schema

Register it once in WordPress, and every client can query it. Here, reading time:

Plaintext
add_action( 'graphql_register_types', function () {
	register_graphql_field( 'Post', 'readingTime', array(
		'type'        => 'Int',
		'description' => __( 'Estimated reading time in minutes.', 'forwp' ),
		'resolve'     => function ( $post ) {
			$text  = wp_strip_all_tags( get_post_field( 'post_content', $post->databaseId ) );
			$words = count( preg_split( '/\s+/u', trim( $text ), -1, PREG_SPLIT_NO_EMPTY ) );

			return max( 1, (int) ceil( $words / 200 ) );
		},
	) );
} );

preg_split with the u flag instead of str_word_count: the latter doesn’t count Cyrillic and other non-Latin words. Add readingTime to the query from step 2, and it’s there.

Step 4: Fragments — One Shape, Many Queries

The list, the single page, and a “related posts” block all need a post card. Define that shape once:

Javascript
// lib/queries.ts
export const POST_CARD = /* GraphQL */ `
  fragment PostCard on Post {
    slug
    title
    date
    excerpt
    readingTime
  }
`;

export const POSTS_QUERY = /* GraphQL */ `
  query Posts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      pageInfo {
        hasNextPage
        endCursor
      }
      nodes {
        ...PostCard
      }
    }
  }
  ${POST_CARD}
`;

When the card design changes, you edit one fragment, not five queries. Keep fragments next to the components that render them, and each component asks for exactly what it displays.

Step 5: Production Limits

The default WPGraphQL setup is developer-friendly, not production-safe. Before launch:

  • Public introspection off. GraphQL → Settings → Enable Public Introspection stays unchecked. Logged-in developers still get the schema in GraphiQL.
  • Query depth limit on. Same settings page, Enable Query Depth Limiting, with a max depth your real queries fit into (10–15 is typical).
  • Cap page size. Nobody should fetch 1,000 posts in one request:
Plaintext
add_filter( 'graphql_connection_max_query_amount', fn () => 50 );
  • Persisted queries. With WPGraphQL Smart Cache, your frontend queries are saved as GraphQL documents on the server, and the allow-list rule makes the server execute only those. The frontend sends a query ID instead of the full query string, so the URL stays short, and arbitrary queries are rejected.

Step 6: Caching and Revalidation

Because requests go out as GET, they’re cacheable by URL: at the CDN, by Smart Cache’s network cache, and by Next.js itself. The revalidate: 300 in the helper is a safety net. The real trigger is publishing.

In Next.js, a route handler invalidates by tag:

Javascript
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';

export async function POST(req: Request) {
  if (req.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  const { slug } = await req.json();
  revalidateTag('posts');
  if (slug) revalidateTag(`post:${slug}`);

  return Response.json({ revalidated: true });
}

In WordPress, call it when a post is published, updated, or unpublished:

Plaintext
add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
	if ( 'post' !== $post->post_type || ( 'publish' !== $new_status && 'publish' !== $old_status ) ) {
		return;
	}

	wp_remote_post( FORWP_FRONTEND_URL . '/api/revalidate', array(
		'headers'  => array(
			'Content-Type'        => 'application/json',
			'x-revalidate-secret' => FORWP_REVALIDATE_SECRET,
		),
		'body'     => wp_json_encode( array( 'slug' => $post->post_name ) ),
		'blocking' => false,
	) );
}, 10, 3 );

publish → publish fires on updates too, so edits to a live post invalidate the cache as well.

Common Mistakes in Practice

N+1 in custom resolvers. A field that runs its own query per node multiplies database calls by list size. For related objects, return a deferred load through WPGraphQL’s loaders so IDs are batched across the whole list:

Plaintext
register_graphql_field( 'Post', 'featuredCase', array(
	'type'    => 'Post',
	'resolve' => function ( $post, $args, $context ) {
		$case_id = (int) get_post_meta( $post->databaseId, 'forwp_featured_case', true );

		return $case_id ? $context->get_loader( 'post' )->load_deferred( $case_id ) : null;
	},
) );

No pagination on connections. “GraphQL handles it” until one query returns every post with content. Always pass first, and cap it server-side.

Business logic inside resolvers. Resolvers should be thin and call the same service layer your REST endpoints use. Otherwise two data paths drift apart.

POST for everything. It works in development and makes caching hard in production. Use GET plus persisted queries for public reads.

Pre-Launch Checklist

  • [ ] Public introspection is off.
  • [ ] Query depth limit is on and tested against real frontend queries.
  • [ ] graphql_connection_max_query_amount is capped.
  • [ ] Frontend queries are persisted, the allow-list is on.
  • [ ] Public reads go as GET and hit a cache.
  • [ ] Publish/update/unpublish triggers revalidation.
  • [ ] Custom resolvers batch related lookups through loaders.
  • [ ] A missing slug returns a real 404, not an empty page.

FAQ

Not for server components. Plain fetch with Next.js caching covers reads. A client library earns its place when you have heavy client-side state, optimistic updates, or subscriptions.

GET for public reads, so responses are cacheable by URL. POST for mutations and authenticated requests.

Install WPGraphQL for ACF and enable Show in GraphQL on the field group. The fields appear on the post type in the schema.

Previews need authenticated requests and bypass the cache. That flow is covered in the preview and authentication parts of the headless series.

Yes, with WPGraphQL Content Blocks: it returns blocks as typed data instead of one HTML string, and you map each block type to a component.

RelatedHeadless & Decoupled WordPress: Architecture, Trade-Offs, and Production PatternsGraphQL for Headless WordPress: Schema Design With WPGraphQLFrontend Architecture for Headless WordPress: Next.js, SPA, and Rendering StrategyBuilding a REST API Backend for Headless WordPressAuthentication for Headless WordPress: Beyond Cookies and NoncesDraft & Preview in a Headless WordPress Setup