Type something to search...
How to Build a Headless WordPress Site with WPGraphQL?

How to Build a Headless WordPress Site with WPGraphQL?

Editors love WordPress. Developers building modern front ends often do not love PHP templates. Headless WordPress lets both sides get what they want: the content team keeps the familiar admin, the block editor, and their workflow, while the front end is built with a JavaScript framework, deployed separately, and served from a CDN. The piece that connects the two is an API, and for most headless projects that API is WPGraphQL. Instead of making several REST requests and receiving large payloads full of fields you do not need, you send one GraphQL query that asks for exactly the posts, fields, images, and authors a page needs.

This article covers building a headless WordPress site with WPGraphQL from start to finish: when headless is the right choice, installing and configuring the plugin, exploring the schema with GraphiQL, writing queries for posts, pages, and menus, building the front end in the Next.js App Router, pagination, previews of draft content with Application Passwords, cache revalidation when editors publish, SEO, and the problems that come up in real projects.

What Headless WordPress Means

In a traditional WordPress site, the same server stores content and renders HTML with a PHP theme. In a headless site, WordPress only stores and manages content. A separate front-end application fetches that content over an API and renders the pages.

AspectTraditional WordPressHeadless WordPress
RenderingPHP theme on the WordPress serverSeparate front end (Next.js, Astro, etc.)
Content editingWordPress adminWordPress admin
Plugins that output HTMLWork automaticallyNeed extra integration
HostingOne serverWordPress backend plus front-end host
Performance ceilingDepends on caching setupStatic or edge-cached pages
Team skills neededPHP, WordPress themingJavaScript framework plus WordPress

Headless is a good fit when you need a highly custom front end, want to serve content to several channels such as a website and a mobile app, or have a front-end team that works in React. It is a poor fit when the site depends on many plugins that render front-end output, such as page builders, forms, and membership tools, because each of those needs a custom replacement in the front end. For a broader comparison of approaches, see how to integrate Next.js with a CMS like WordPress or Contentful.

WPGraphQL vs the REST API

WordPress ships with a REST API, covered in what is the WordPress REST API, and how to use it. So why add a plugin?

  • One request per page. A blog post page needs the post, its author, featured image, categories, and related posts. With REST that is several requests or heavy use of _embed. With GraphQL it is one query.
  • No over-fetching. You get only the fields you ask for, which keeps responses small.
  • A typed schema. Every type and field is documented and discoverable, and tools can generate TypeScript types from it.
  • Ecosystem. Extensions add Advanced Custom Fields, SEO plugins, WooCommerce, and more to the same schema.

WPGraphQL is free, open source, and published on WordPress.org. It is in the process of becoming a canonical plugin on WordPress.org, which means long-term stewardship within the WordPress project.

Setting Up WordPress

Installing WPGraphQL

  1. In the WordPress admin, go to Plugins → Add New Plugin.
  2. Search for WPGraphQL and install it.
  3. Activate the plugin.

Or with WP-CLI:

# Terminal
wp plugin install wp-graphql --activate

WPGraphQL requires WordPress 6.0 or later and PHP 7.4 or later. After activation, a GraphQL menu item appears in the admin, and the API is available at /graphql, for example https://cms.example.com/graphql.

Configuring Permalinks and Settings

Pretty permalinks must be enabled for the /graphql endpoint to work. Under Settings → Permalinks, choose any structure other than Plain. Then review GraphQL → Settings:

  • GraphQL endpoint: leave it as graphql unless you have a reason to change it.
  • Public introspection: introspection lets tools read the whole schema. Enabling it for public requests helps during development and with type generation. Many teams keep it disabled in production and only allow it for authenticated users.
  • Debug mode: shows extra error details. Enable it locally only.
  • Batch queries and query depth limits: keep reasonable limits to protect the server from expensive queries.

Using the GraphiQL IDE

WPGraphQL includes the GraphiQL IDE in the admin under GraphQL → GraphiQL IDE. It has a query editor with autocompletion, a documentation explorer for every type, and a query composer where you click fields to build a query. Write and test every query here before putting it in your front end.

Writing Your First Queries

Listing Posts

This query returns the ten most recent posts with the fields a blog listing needs:

# queries/recent-posts.graphql
query RecentPosts {
  posts(first: 10, where: { status: PUBLISH }) {
    nodes {
      databaseId
      title
      slug
      date
      excerpt
      featuredImage {
        node {
          sourceUrl
          altText
          mediaDetails {
            width
            height
          }
        }
      }
      categories {
        nodes {
          name
          slug
        }
      }
    }
  }
}

Lists in WPGraphQL follow the connection pattern. You ask for nodes to get the items, and pageInfo when you need pagination.

Fetching a Single Post by Slug

# queries/post-by-slug.graphql
query PostBySlug($slug: ID!) {
  post(id: $slug, idType: SLUG) {
    databaseId
    title
    date
    modified
    content
    excerpt
    author {
      node {
        name
        avatar {
          url
        }
      }
    }
    featuredImage {
      node {
        sourceUrl
        altText
      }
    }
  }
}

The idType argument tells WPGraphQL how to interpret id. Options include SLUG, DATABASE_ID, URI, and the default global ID.

Pages and Menus

Pages and menus work the same way:

# queries/page-and-menu.graphql
query PageAndMenu($uri: ID!) {
  page(id: $uri, idType: URI) {
    title
    content
  }
  menuItems(where: { location: PRIMARY }, first: 50) {
    nodes {
      id
      label
      uri
      parentId
    }
  }
}

The menu location values come from the locations your theme registers, so a headless setup typically keeps a minimal theme on the WordPress side that registers menu locations.

Building the Front End with Next.js

The examples use the Next.js App Router with TypeScript, but the queries work from any framework. If you are new to GraphQL in Next.js, how to use Next.js with a GraphQL API covers the basics.

Environment Variables

# .env.local
WORDPRESS_GRAPHQL_URL=https://cms.example.com/graphql
WORDPRESS_PREVIEW_USER=preview-bot
WORDPRESS_APPLICATION_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx"
REVALIDATE_SECRET=a-long-random-string

None of these use the NEXT_PUBLIC_ prefix, so they stay on the server and are never sent to the browser.

A Typed Fetch Helper

A small helper sends queries, handles errors, and integrates with Next.js caching:

// lib/wordpress.ts
import "server-only";

type GraphQLResponse<T> = {
  data?: T;
  errors?: { message: string }[];
};

type FetchOptions = {
  variables?: Record<string, unknown>;
  tags?: string[];
  revalidate?: number | false;
  authenticated?: boolean;
};

export async function wpFetch<T>(
  query: string,
  {
    variables,
    tags = ["wordpress"],
    revalidate = 3600,
    authenticated = false,
  }: FetchOptions = {},
): Promise<T> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };

  if (authenticated) {
    const credentials = Buffer.from(
      `${process.env.WORDPRESS_PREVIEW_USER}:${process.env.WORDPRESS_APPLICATION_PASSWORD}`,
    ).toString("base64");
    headers.Authorization = `Basic ${credentials}`;
  }

  const res = await fetch(process.env.WORDPRESS_GRAPHQL_URL!, {
    method: "POST",
    headers,
    body: JSON.stringify({ query, variables }),
    ...(authenticated
      ? { cache: "no-store" as const }
      : { next: { revalidate, tags } }),
  });

  if (!res.ok) {
    throw new Error(`WordPress request failed with status ${res.status}`);
  }

  const json = (await res.json()) as GraphQLResponse<T>;

  if (json.errors?.length) {
    throw new Error(json.errors.map((e) => e.message).join("\n"));
  }

  return json.data as T;
}

Public requests are cached and tagged so they can be revalidated later. Authenticated preview requests are never cached, so draft content cannot leak into the public cache.

The Blog Index Page

// app/blog/page.tsx
import Link from "next/link";
import Image from "next/image";
import { wpFetch } from "@/lib/wordpress";

type Post = {
  databaseId: number;
  title: string;
  slug: string;
  date: string;
  excerpt: string;
  featuredImage: { node: { sourceUrl: string; altText: string } } | null;
};

const RECENT_POSTS = `
  query RecentPosts {
    posts(first: 12, where: { status: PUBLISH }) {
      nodes {
        databaseId
        title
        slug
        date
        excerpt
        featuredImage { node { sourceUrl altText } }
      }
    }
  }
`;

export default async function BlogPage() {
  const data = await wpFetch<{ posts: { nodes: Post[] } }>(RECENT_POSTS, {
    tags: ["wordpress", "posts"],
  });

  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {data.posts.nodes.map((post) => (
          <li key={post.databaseId}>
            {post.featuredImage && (
              <Image
                src={post.featuredImage.node.sourceUrl}
                alt={post.featuredImage.node.altText}
                width={640}
                height={360}
              />
            )}
            <h2>
              <Link href={`/blog/${post.slug}`}>{post.title}</Link>
            </h2>
            <time dateTime={post.date}>
              {new Date(post.date).toLocaleDateString("en-US")}
            </time>
          </li>
        ))}
      </ul>
    </main>
  );
}

To use next/image with images hosted on WordPress, allow the domain in next.config.ts:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "cms.example.com",
        pathname: "/wp-content/uploads/**",
      },
    ],
  },
};

export default nextConfig;

The Single Post Page

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { wpFetch } from "@/lib/wordpress";

type SinglePost = {
  title: string;
  date: string;
  content: string;
  excerpt: string;
  author: { node: { name: string } } | null;
} | null;

const POST_BY_SLUG = `
  query PostBySlug($slug: ID!) {
    post(id: $slug, idType: SLUG) {
      title
      date
      content
      excerpt
      author { node { name } }
    }
  }
`;

const ALL_SLUGS = `
  query AllSlugs {
    posts(first: 100, where: { status: PUBLISH }) {
      nodes { slug }
    }
  }
`;

async function getPost(slug: string) {
  const data = await wpFetch<{ post: SinglePost }>(POST_BY_SLUG, {
    variables: { slug },
    tags: ["wordpress", `post:${slug}`],
  });
  return data.post;
}

export async function generateStaticParams() {
  const data = await wpFetch<{ posts: { nodes: { slug: string }[] } }>(
    ALL_SLUGS,
  );
  return data.posts.nodes.map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return {};
  return {
    title: post.title,
    description: post.excerpt
      .replace(/<[^>]+>/g, "")
      .trim()
      .slice(0, 160),
  };
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>
        {post.author?.node.name} ·{" "}
        <time dateTime={post.date}>
          {new Date(post.date).toLocaleDateString("en-US")}
        </time>
      </p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

A few notes on this page:

  • params is a Promise in current Next.js versions, so it is awaited. On Next.js 14 and earlier, params was a plain object.
  • generateStaticParams prebuilds the most recent posts at build time. Posts not in that list are rendered on the first request and then cached.
  • content is HTML rendered by WordPress from the block editor. Rendering it with dangerouslySetInnerHTML is standard for headless WordPress, because the HTML comes from your own trusted editors. Do not render HTML from untrusted sources this way.
  • Block styles are not included automatically. Import the core block library CSS or write styles for the block classes you use, such as wp-block-image and wp-block-quote.

Pagination

WPGraphQL uses cursor-based pagination. Request pageInfo alongside the nodes:

# queries/paginated-posts.graphql
query PaginatedPosts($first: Int!, $after: String) {
  posts(first: $first, after: $after, where: { status: PUBLISH }) {
    pageInfo {
      hasNextPage
      endCursor
    }
    nodes {
      databaseId
      title
      slug
    }
  }
}

Pass endCursor as the after variable to fetch the next page. Cursors work naturally with "Load more" buttons and infinite scroll. If your design needs numbered pages like /blog/page/3, you either walk through cursors on the server or install an extension that adds offset pagination, because cursor pagination does not support jumping to an arbitrary page.

Previewing Drafts

Editors expect the Preview button to show unpublished changes. In a headless setup, that means the front end must fetch draft content, which requires authentication.

  • Create a dedicated user in WordPress, for example preview-bot, with the Editor role so it can read drafts.
  • Create an Application Password for that user under Users → Profile → Application Passwords. WPGraphQL accepts Application Passwords sent with HTTP Basic authentication, as explained in how to use Application Passwords in WordPress. Always use HTTPS.
  • Enable Draft Mode in Next.js through a route handler that checks a secret:
// app/api/preview/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get("secret");
  const id = searchParams.get("id");

  if (secret !== process.env.REVALIDATE_SECRET || !id || !/^\d+$/.test(id)) {
    return new Response("Invalid preview request", { status: 401 });
  }

  (await draftMode()).enable();
  redirect(`/preview/${id}`);
}
  • Render the preview by fetching the post by database ID with authentication. Drafts often have no slug yet, so the ID is the reliable identifier:
// app/preview/[id]/page.tsx
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { wpFetch } from "@/lib/wordpress";

const PREVIEW_POST = `
  query PreviewPost($id: ID!) {
    post(id: $id, idType: DATABASE_ID, asPreview: true) {
      title
      content
    }
  }
`;

export default async function PreviewPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { isEnabled } = await draftMode();
  if (!isEnabled) notFound();

  const { id } = await params;
  const data = await wpFetch<{
    post: { title: string; content: string } | null;
  }>(PREVIEW_POST, { variables: { id }, authenticated: true });
  if (!data.post) notFound();

  return (
    <article>
      <p>Preview mode</p>
      <h1>{data.post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: data.post.content }} />
    </article>
  );
}
  • Point the WordPress Preview button at the front end with the preview_post_link filter in a small plugin or mu-plugin on the WordPress side:
<?php
// wp-content/mu-plugins/headless-preview.php

/**
 * Plugin Name: Headless Preview Links
 */

add_filter( 'preview_post_link', function ( $link, $post ) {
if ( ! defined( 'HEADLESS_FRONTEND_URL' ) || ! defined( 'HEADLESS_PREVIEW_SECRET' ) ) {
return $link;
}

return add_query_arg(
array(
'secret' => HEADLESS_PREVIEW_SECRET,
'id'     => $post->ID,
),
trailingslashit( HEADLESS_FRONTEND_URL ) . 'api/preview'
);
}, 10, 2 );

Define HEADLESS_FRONTEND_URL and HEADLESS_PREVIEW_SECRET in wp-config.php so the secret is not stored in code.

Revalidating When Content Changes

Cached pages must update when editors publish. The cleanest approach is on-demand revalidation: WordPress calls a front-end endpoint after a post is saved, and the front end invalidates the matching cache tags.

The route handler on the Next.js side:

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

export async function POST(request: Request) {
  if (
    request.headers.get("x-revalidate-secret") !== process.env.REVALIDATE_SECRET
  ) {
    return Response.json({ message: "Invalid secret" }, { status: 401 });
  }

  const { slug } = (await request.json()) as { slug?: string };

  revalidateTag("posts", "max");
  if (slug) revalidateTag(`post:${slug}`, "max");

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

In Next.js 16, revalidateTag takes a cache profile as its second argument; "max" marks the data stale and refreshes it in the background on the next visit. In Next.js 15 and earlier, call it with the tag only.

And the WordPress side, which sends a request when a published post is saved:

<?php
// wp-content/mu-plugins/headless-revalidate.php

/**
 * Plugin Name: Headless Revalidation
 */

add_action( 'save_post_post', function ( $post_id, $post ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( 'publish' !== $post->post_status ) {
return;
}
if ( ! defined( 'HEADLESS_FRONTEND_URL' ) || ! defined( 'HEADLESS_REVALIDATE_SECRET' ) ) {
return;
}

wp_remote_post(
trailingslashit( HEADLESS_FRONTEND_URL ) . 'api/revalidate',
array(
'headers'  => array(
  'Content-Type'        => 'application/json',
  'x-revalidate-secret' => HEADLESS_REVALIDATE_SECRET,
),
'body'     => wp_json_encode( array( 'slug' => $post->post_name ) ),
'timeout'  => 5,
'blocking' => false,
)
);
}, 10, 2 );

Setting blocking to false means editors do not wait for the front end to respond when they click Update. For deletions and unpublishing, hook transition_post_status as well, so a post that moves from published to draft disappears from the front end.

SEO for Headless WordPress

Search engines see your front end, not WordPress, so SEO must be handled there:

  • Titles and descriptions: generate them in generateMetadata, as in the single post example. If you use an SEO plugin, install its WPGraphQL extension so its titles, descriptions, canonical URLs, and Open Graph data are available in the schema.
  • Canonical URLs: point them at the front-end domain, never the WordPress backend.
  • Sitemaps: generate them on the front end from a query for all slugs.
  • Block the backend from indexing: the WordPress domain should not compete with your front end. Add a noindex header on the CMS domain or protect it entirely, and make sure the API endpoint stays reachable for the front end.
  • Redirects: when editors change slugs, the front end needs to return 301 redirects from the old URLs.

Performance and Security

  • Cache at every layer. Use Next.js caching with tags on the front end. On the WordPress side, persistent object caching speeds up GraphQL resolvers, and the WPGraphQL Smart Cache extension adds network caching for GraphQL GET requests with automatic invalidation.
  • Ask only for what you need. Avoid huge first values and deeply nested queries in a single request.
  • Keep credentials server-side. Application Passwords and secrets belong in server environment variables, never in client components.
  • Restrict the admin. Put the WordPress admin behind strong passwords and two-factor authentication. The front end only needs the /graphql endpoint.
  • Limit query complexity. Use the query depth and batching settings to protect the server from expensive or abusive queries.

Common Problems and Fixes

  • /graphql returns a 404. Permalinks are set to Plain. Choose another structure under Settings, Permalinks, and save.
  • post returns null for a slug that exists. The post is a draft or private and the request is not authenticated, or the post belongs to a custom post type. Check its status and query the correct type.
  • Custom post types do not appear in the schema. They must opt in with show_in_graphql, graphql_single_name, and graphql_plural_name in register_post_type.
  • Images fail in next/image. The WordPress domain is not in images.remotePatterns.
  • Published changes do not appear on the front end. The revalidation request is failing. Check the secret, the URL, and the front end's logs, and confirm the cache tags match.
  • Preview shows the published version. The query is missing authentication or asPreview: true, or the request was cached. Use cache: "no-store" for preview requests.
  • Block content looks unstyled. Block markup relies on core block CSS. Include the block library styles or style the block classes yourself.

Headless WordPress with WPGraphQL FAQ

Yes. WPGraphQL is a free, open-source plugin available on WordPress.org. Most extensions for other plugins are free as well.

WordPress needs an active theme to run, but it can be minimal. Many headless projects use a tiny theme that registers menu locations and redirects front-end visits to the real site.

Use WPGraphQL when pages need related data from several sources and you want small, precise responses. The REST API is built in and is fine for simple integrations, server-to-server jobs, and when you only need one resource type at a time.

Plugins that work in the admin or store data, such as SEO, custom fields, and editorial tools, still work. Plugins that output HTML on the front end, such as forms, sliders, and page builders, do not render on your front end and need a replacement or an API integration.

Point the WordPress preview link at a front-end route that enables draft mode, then fetch the draft over WPGraphQL with an authenticated request, for example using an Application Password, with caching disabled for that request.

Yes. WPGraphQL is a standard GraphQL endpoint, so any framework or language that can send an HTTP POST request can use it, including Astro, Nuxt, SvelteKit, Remix, and mobile apps.

Conclusion

Headless WordPress with WPGraphQL keeps the editing experience your content team knows while giving developers a typed, efficient API and full control over the front end. The setup is straightforward: install WPGraphQL, enable pretty permalinks, design your queries in GraphiQL, and fetch them from a server-side helper in your framework.

The work that makes a headless site production-ready is in the details around it: cache tags plus a revalidation webhook so published changes appear quickly, authenticated draft previews for editors, SEO metadata and sitemaps generated on the front end, and a locked-down WordPress backend. Get those right and you have a fast, secure site that is still pleasant to edit.

Here are some useful references for going deeper on headless WordPress with WPGraphQL:

  1. WordPress.org Plugins: WPGraphQL — the plugin page, requirements, and changelog.
  2. WPGraphQL Docs: Introduction to WPGraphQL — concepts, the schema, and connections.
  3. WPGraphQL Docs: Authentication and Authorization — Application Passwords and other auth methods.
  4. Next.js Docs: Draft Mode — previewing unpublished content in the App Router.
  5. Next.js Docs: revalidateTag — on-demand cache invalidation.
Tags :
Share :

Related Posts

WordPress optimization with specific recommended approach

WordPress optimization with specific recommended approach

Whether you run a high traffic WordPress installation or a small blog on a low cost shared host, you should optimize WordPress and your server to run

Continue Reading
Creating and Customizing WordPress Child Themes

Creating and Customizing WordPress Child Themes

Creating a child theme in WordPress is a best practice for making modifications to a theme. By using a child theme, you can update the parent theme w

Continue Reading
Understanding the Distinction Categories vs. Tags in WordPress

Understanding the Distinction Categories vs. Tags in WordPress

WordPress, a powerful content management system, offers a plethora of features to organize content effectively. Among these features, categories and

Continue Reading