The “Headless WordPress” revolution has matured. In 2026, the question is no longer if you should decouple your frontend, but how you should fetch your data. The choice sits between the native REST API in WordPress core and GraphQL, which on WordPress almost always means the WPGraphQL plugin.
The decision now rests on engineering requirements you can measure: how many requests a page needs, how much JSON the browser has to parse, how responses get cached at the edge, how the frontend gets its types, and who maintains the extra code. Both options are mature. This guide compares them on those points, with every API name, setting and default checked against the REST API Handbook and the WPGraphQL documentation.
1. Differences between REST API and GraphQL in WordPress
REST (endpoint-driven)
GET /wp-json/wp/v2/posts/101
GET /wp-json/wp/v2/users/4
GET /wp-json/wp/v2/categories?post=101
= several HTTP requests, each with a fixed response shape
GraphQL (document-driven)
POST /graphql
query SinglePost {
post(id: "101", idType: DATABASE_ID) {
title
date
author { node { name } }
categories { nodes { name slug } }
}
}
= one HTTP request, the client names every field it wantsThe REST API is built on fixed resources. A request to /wp-json/wp/v2/posts returns the full post representation: rendered content, excerpt, GUID, comment status, links and more. WPGraphQL inverts this. The client sends a query document, and the server returns only the fields that document names.
Over-fetching and under-fetching in the REST API
The WPGraphQL team describes the REST problem as simultaneous over-fetching and under-fetching: the posts endpoint returns category IDs, tag IDs, content and excerpt that a list view does not need, yet still lacks the author name and avatar, which takes more requests (WPGraphQL vs WP REST API).
Core has two answers to this, and they are often overlooked. The _fields global parameter returns only a subset of fields, for example ?_fields=id,title,link, and since WordPress 5.3 it supports nested properties such as a single meta key. The _embed parameter includes linked resources (author, featured media, terms) in the same response, and since WordPress 5.4 you can limit it to specific link relations (Global parameters). With both, a REST list view can come close to a GraphQL payload. What REST still lacks is a typed schema the client can query against.
2. WordPress REST API pros and cons
The WordPress REST API has been part of core for a decade, and it is more robust than ever.
- The strength: It works out of the box. No extra plugins are required for basic functionality. It is highly cacheable at the HTTP level using standard CDNs, because every resource has its own URL.
- The weakness: Over-fetching by default. If you only need a post title and slug and forget
_fields, REST sends the whole post object, including content and metadata. This wastes bandwidth, especially on mobile.
The REST API uses JSON for requests and responses, and any language that can make HTTP requests and read JSON can use it (REST API Handbook). That is why almost every integration platform, CRM connector and webhook consumer speaks REST first.
3. WPGraphQL pros and cons
GraphQL (usually implemented via WPGraphQL) allows the frontend developer to define the shape of the response.
- The strength: One request, exactly the data you want. You can fetch a post, its categories, the author’s latest 3 posts and the site settings in a single network round-trip. The schema is strongly typed, and every field declares its type.
- The weakness: It requires an additional plugin to maintain. Query complexity can sometimes lead to slow database lookups if it is not optimised with an object cache (Redis).
The plugin facts, read from the wordpress.org plugin directory on 14 October 2026: WPGraphQL 2.24.0, requires WordPress 6.0 and PHP 7.4, tested up to WordPress 7.0.7, more than 30,000 active installations. The plugin page states that WPGraphQL is becoming a Canonical Plugin on WordPress.org. It follows Semantic Versioning, so a major version bump signals breaking changes and is worth reading in the release notes before you update.
4. How WPGraphQL solves the N+1 problem
A naive resolver for “the last 20 posts and each author’s name” runs one query for the posts and then one query per author. That is the N+1 problem. WPGraphQL avoids it with the DataLoader pattern: during resolution it collects the IDs it needs (all post_author values, for example) and loads them in one batch. In the WPGraphQL documentation’s own example, the SQL queries for five posts’ authors drop from five to one (WPGraphQL vs WP REST API).
SELECT * FROM wp_users WHERE ID IN (2, 5, 8, 14);On top of that, WPGraphQL has a Model Layer: a set of PHP classes that wrap WordPress objects and check sensitive fields, such as a user’s email address, before they are returned (WPGraphQL concepts).
How to extend the WPGraphQL schema in PHP
<?php
declare(strict_types=1);
add_action('graphql_register_types', function (): void {
register_graphql_object_type('PerformanceMetrics', [
'description' => __('Measured Core Web Vitals for a post', 'wppoland'),
'fields' => [
'lcpSeconds' => ['type' => 'Float'],
'inpMilliseconds' => ['type' => 'Int'],
'clsScore' => ['type' => 'Float'],
],
]);
register_graphql_field('Post', 'cwvMetrics', [
'type' => 'PerformanceMetrics',
'resolve' => function ($post): ?array {
$raw = get_post_meta($post->databaseId, '_wppoland_cwv_data', true);
if (!is_array($raw)) {
return null;
}
return [
'lcpSeconds' => isset($raw['lcp']) ? (float) $raw['lcp'] : null,
'inpMilliseconds' => isset($raw['inp']) ? (int) $raw['inp'] : null,
'clsScore' => isset($raw['cls']) ? (float) $raw['cls'] : null,
];
},
]);
});5. How to create a custom REST endpoint in WordPress
The core pattern is register_rest_route() on the rest_api_init hook, or a class that extends WP_REST_Controller. The handbook rules that matter in production (Adding custom endpoints):
- Since WordPress 5.5, a route registered without
permission_callbacktriggers a_doing_it_wrongnotice. For a public endpoint, pass__return_trueexplicitly. - Namespaces should follow the
vendor/v1pattern, and the version goes up when you break compatibility. - Errors are a
WP_Errorwith the HTTP code set in thestatuskey of its data, for example400for bad input. - When you wrap an existing callback, run its return value through
rest_ensure_response().
<?php
declare(strict_types=1);
class Wppoland_Metrics_Controller extends WP_REST_Controller
{
protected $namespace = 'wppoland/v1';
protected $rest_base = 'metrics';
public function register_routes(): void
{
register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<id>\d+)', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_item'],
'permission_callback' => '__return_true',
'args' => [
'id' => ['type' => 'integer', 'required' => true],
],
],
'schema' => [$this, 'get_public_item_schema'],
]);
}
public function get_item($request)
{
$post = get_post((int) $request['id']);
if (!$post || $post->post_status !== 'publish') {
return new WP_Error('rest_post_invalid_id', __('Not found.', 'wppoland'), ['status' => 404]);
}
return rest_ensure_response([
'id' => $post->ID,
'title' => get_the_title($post),
]);
}
public function get_item_schema(): array
{
return [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'wppoland_metrics',
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
],
];
}
}
add_action('rest_api_init', function (): void {
(new Wppoland_Metrics_Controller())->register_routes();
});The properties are declared without a type on purpose: WP_REST_Controller declares $namespace and $rest_base untyped, and PHP refuses a typed redeclaration in a child class. For the full controller walkthrough see our guide to custom REST API endpoints.
6. Is GraphQL faster than REST in WordPress
In 2026, performance isn’t just about speed; it’s about efficiency.
- Network latency: GraphQL wins on complex pages. By bundling multiple data points into one request, it removes the sequential round-trips that a REST page with post, author, terms and related posts needs.
- Caching strategy: REST wins on ease of use. Since every endpoint is a unique URL, you can cache it globally on Cloudflare with little effort. GraphQL needs GET requests or persisted queries to achieve similar edge-caching results.
- Server CPU: REST is usually lighter for one simple resource. GraphQL has to parse and validate the query document before it resolves anything, which is why an object cache matters more on a GraphQL site.
The WPGraphQL team published one measurement: fetching 100 posts took 335 kb and 7.91 s from the REST endpoint against 6.4 kb and 67 ms from WPGraphQL (WPGraphQL vs WP REST API). Treat that as the vendor’s own test with default REST output. A REST request with _fields closes much of the payload gap, so measure your own pages before you choose on this argument alone.
7. How to cache GraphQL at the CDN with persisted queries
GraphQL queries are usually sent as HTTP POST with the query in the body, and a CDN does not cache POST by URL. WPGraphQL’s own guidance is that POST is the typical method and GET has limited support (Interacting with WPGraphQL). Edge caching is handled by the companion plugin WPGraphQL Smart Cache (version 2.3.3, more than 6,000 active installations, now part of the WPGraphQL monorepo, per its plugin page).
How it works, according to the Smart Cache documentation:
- Network cache: to benefit from it you send HTTP GET requests for public data. Responses carry an
X-GraphQL-Keysheader with tags for everything the query touched. When content changes, Smart Cache sends purge events, and the host evicts the cached documents tagged with those keys. The guide lists WP Engine and Pantheon as supporting hosts. The defaultmax-ageis 600 seconds, adjustable in the plugin’s Cache settings (Network cache). - Persisted queries: a query document is stored on the server in a non-public
graphql_documentpost type and referenced by a query ID. The document slug is the SHA-256 hash of the query, and a client can register a newqueryIdonly if it matches that hash. Clients then send short GET requests such as?queryId=getPostById&variables={...}. The plugin works with the Apollo Persisted Query Link (Persisted queries). - Allow and deny lists: under the saved queries settings you choose between a fully public endpoint, allowing only specific saved queries, or denying specific ones. Users with
manage_optionsare exempt.
With GET requests, persisted queries and tag-based purging in place, a GraphQL response is cached at the edge much like a REST URL. Without them, GraphQL caching falls back to the WordPress object cache.
8. WordPress REST batch API vs GraphQL batching
WordPress 5.6 added a batch framework at /wp-json/batch/v1. It accepts one POST with several sub-requests, 25 by default (filter rest_get_max_batch_size). GET requests are not supported, so it is meant for writes, and each route must opt in with allow_batch when it is registered. A require-all-validate mode rejects the whole batch if any request fails validation (REST API batch framework in WordPress 5.6).
Limitations of batching in the REST API
- No data composition: sub-requests run as separate operations. You cannot feed the result of request A (an author ID) into request B in the same batch.
- Write-oriented: without GET, the batch endpoint does not help a read-heavy frontend at all.
- Not cacheable at the edge: it is a POST, so a CDN passes every batch to the origin.
GraphQL resolves related data inside one query by design, and WPGraphQL also accepts batched queries. Its security documentation notes that you can limit or disable batch queries on the GraphQL settings page.
9. Pagination limits in REST and GraphQL
The REST API caps per_page at 100 records and returns two headers with each collection: X-WP-Total (total records) and X-WP-TotalPages (total pages) (Pagination). A client that needs 450 posts makes five requests.
WPGraphQL uses cursor-based connections (first, after, pageInfo { hasNextPage endCursor }). It caps the items per connection page at 100 for most connections, and the graphql_connection_max_query_amount filter changes that cap (WPGraphQL security). Raise it only together with the depth limit and caching, because larger pages make expensive queries cheaper to send.
10. Authentication in the WordPress REST API and WPGraphQL
The REST API has two built-in methods (Authentication):
- Cookie authentication for code running in the logged-in dashboard. A manual request must send a nonce for the
wp_restaction, usually in theX-WP-Nonceheader. Without the nonce the request is treated as unauthenticated, even if the user is logged in. - Application Passwords, shipped in core since WordPress 5.6. They use Basic Auth and work only over HTTPS.
WPGraphQL follows WordPress permissions: anything WordPress exposes publicly is public in GraphQL, and data that needs authentication in WordPress needs an authenticated GraphQL request. Users without published posts are excluded from public user queries, and fields such as a user’s email require specific permission (WPGraphQL security). Token-based login flows (JWT and similar) come from extensions, not from the core plugin.
11. How to secure the REST API and GraphQL in WordPress
| Measure | WPGraphQL | REST API |
|---|---|---|
| Schema discovery | Public introspection is disabled by default; enable it in GraphQL settings only if you need it | The /wp-json/ index lists routes; require authentication with rest_authentication_errors if the site has no public consumers |
| Query depth | New installs limit depth to 15; filter graphql_query_depth_max | Not applicable, response shapes are fixed per endpoint |
| Query cost | Cost-based complexity limits are not in the plugin yet (tracked in issue #3922) | Not applicable |
| Page size | 100 items per connection page by default | per_page capped at 100 |
| Batching | Batch queries can be limited or disabled | Routes must opt in with allow_batch |
| Write authentication | WordPress permissions; tokens via extensions | Cookie plus nonce, or Application Passwords over HTTPS |
Sources: WPGraphQL security, REST API Handbook FAQ. The handbook’s way to block anonymous REST access is an is_user_logged_in() check on the rest_authentication_errors filter. Rate limiting belongs at the edge (WAF or CDN) for both APIs, because neither plugin nor core ships one.
12. Generate TypeScript types from WPGraphQL
Because the GraphQL schema is typed and introspectable, tools such as GraphQL Code Generator can read the schema and the frontend’s queries and generate TypeScript types for every query result. A field that becomes nullable or disappears from the schema then fails the type check in the editor and in CI, before it reaches production.
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
import { print } from 'graphql';
export async function executeGraphQL<TData, TVariables>(
document: TypedDocumentNode<TData, TVariables>,
variables?: TVariables
): Promise<TData> {
const response = await fetch(import.meta.env.WP_GRAPHQL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: print(document), variables }),
});
if (!response.ok) throw new Error(`GraphQL HTTP ${response.status}`);
const { data, errors } = await response.json();
if (errors?.length) throw new Error(errors[0].message);
return data;
}The REST API publishes a JSON Schema for each route, so types can be generated there too, but you assemble them per endpoint and the server does not validate your client’s field selection.
13. Error handling in the REST API and GraphQL
A REST error is a WP_Error serialised as JSON with code, message and data.status, and the HTTP status of the response matches data.status. If the request fails, the whole response is an error. That is predictable for monitoring, but a page built from one REST call cannot render half of its data.
GraphQL can return partial data. The response carries both data and errors, and each error has a path that points to the field that failed:
{
"data": { "post": { "title": "Headless WordPress architecture", "relatedPosts": null } },
"errors": [
{ "message": "Internal server error", "path": ["post", "relatedPosts"] }
]
}The frontend can render the article and show a fallback only for the block that failed. The flip side: a GraphQL request that partly failed still returns HTTP 200, so monitoring has to read the errors array, not only status codes.
14. GraphQL mutations and optimistic UI in WordPress
Writes in GraphQL are mutations. A mutation changes data and returns the updated object in the same response, so the client can refresh its local state without a second request. Core WPGraphQL ships mutations for posts, terms, users and comments, for example createComment:
mutation AddComment($input: CreateCommentInput!) {
createComment(input: $input) {
success
comment { id content date }
}
}This makes optimistic UI simple: the interface shows the new comment immediately and rolls back only if the mutation returns an error. WooCommerce cart mutations are not part of WPGraphQL core; they come from a separate extension. With REST the same flow is a POST followed by reading the returned resource, which works just as well for single writes.
15. GraphQL or REST for AI agents and the Abilities API
AI agents and MCP clients are now regular API consumers. GraphQL introspection lets an agent read the schema and see which types and arguments exist, but WPGraphQL keeps public introspection off by default, so an agent needs authenticated access or an exported schema.
WordPress core went another way. The Abilities API, in core since WordPress 6.9 (2 December 2025), registers operations with an input and output schema, a permission callback and an execute callback, and exposes them through the wp-abilities/v1 REST endpoints and the MCP Adapter (Abilities API in WordPress 6.9). For agents that act on a site, this REST-based layer is the native path. More detail in our guide to the WordPress Abilities API.
16. Testing WordPress APIs with GraphiQL and Postman
- REST: Familiar to almost every developer. You can test endpoints in a browser or tools like Postman instantly. Add
?_envelopewhen a client cannot read headers: the response then carries headers and status code in the body. - GraphQL: WPGraphQL ships GraphiQL, an in-browser IDE in the WordPress admin that lets you browse the entire schema, see available fields and test queries and mutations with auto-complete. For frontend teams this is a real productivity gain.
For regression safety, compare the frontend’s queries against the schema in CI on every merge, so a removed or retyped field fails the build. On the REST side, validate responses against each route’s JSON Schema.
17. How to migrate from the REST API to WPGraphQL without downtime
- Coexistence: install WPGraphQL on staging with
wp plugin install wp-graphql --activateand map every custom post type and taxonomy into the schema. REST keeps serving production. - Read-only pages first: move archive and listing pages to GraphQL queries, with generated types, and compare payload and response time against the REST version.
- Detail pages and caching: move single views, then add Smart Cache with GET requests or persisted queries if your host supports network caching.
- Clean up REST exposure: keep REST routes that webhooks, automations and integrations use, and require authentication where no public consumer remains.
18. REST vs. GraphQL comparison table
| Feature | REST API | GraphQL (WPGraphQL) |
|---|---|---|
| Setup | Native (built-in) | Needs plugin |
| Data fetching | Fixed endpoints | Flexible, client-defined |
| Over-fetching | Default, reduced with _fields | Avoided by the query |
| Related data | Extra requests or _embed | One query |
| Caching | Easy (URL-based) | GET requests or persisted queries |
| Typing | JSON Schema per route, not enforced on the client | Strongly typed schema |
| Page size cap | 100 per page | 100 per connection page |
| Ideal for | Simple apps, webhooks, integrations | Complex JS apps, headless |
When to use GraphQL instead of REST in WordPress
If you are building a modern, decoupled site using React, Astro, or Next.js, GraphQL is usually the better fit. The ability to request exactly what is needed, with generated types, makes for a leaner and more maintainable frontend.
However, don’t ignore REST. For simple integrations, mobile apps that only need a feed of posts, internal automation, webhooks and AI agents using the Abilities API, the native REST API remains the fastest way to get the job done, with no plugin to maintain. Many production sites run both: GraphQL for the public frontend, REST for integrations.
Learn more about website migration to Astro and Next.js at WPPoland.
Are you still fetching the whole post object just for a title? Add _fields today, and plan the move to GraphQL when the frontend outgrows it.







