Every headless WordPress project starts with the same question: should the frontend talk to the built-in REST API or install WPGraphQL? Both are mature, both work with Next.js, and both can power a fast site. They differ in how you ask for data, how you cache it and what you have to maintain.
The same page, two waysLink to this section
Imagine an article page that needs the title, content, date, featured image, author name and categories.
REST API, using _embed to include related objects and _fields to trim the response:
const url = new URL(`${WP}/wp-json/wp/v2/posts`);
url.searchParams.set("slug", slug);
url.searchParams.set("_embed", "author,wp:featuredmedia,wp:term");
url.searchParams.set("_fields", "id,slug,title,content,date,modified,_links,_embedded");
const [post] = await (await fetch(url, { next: { revalidate: 600, tags: ["wp"] } })).json();WPGraphQL, asking for exactly the fields the page renders:
const query = /* GraphQL */ `
query Post($slug: ID!) {
post(id: $slug, idType: SLUG) {
title
content
date
featuredImage { node { sourceUrl altText mediaDetails { width height } } }
author { node { name } }
categories { nodes { name slug } }
}
}
`;
const res = await fetch(`${WP}/graphql`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables: { slug } }),
next: { revalidate: 600, tags: ["wp"] },
});
const { data, errors } = await res.json();Both are one request. The GraphQL response is shaped exactly like the query, which many developers find easier to work with; the REST response needs a mapping step to pull things out of _embedded. Paste either response into the JSON formatter to compare the shapes side by side.
Where WPGraphQL shinesLink to this section
- Deeply nested data in one request. A landing page with a hero, a list of team members with their photos and roles, and three related posts each with categories is one GraphQL query. With REST it's often several requests.
- No over-fetching. You get the fields you name, nothing else.
- A typed schema. Tools like GraphQL Code Generator produce TypeScript types from your queries, so the frontend knows exactly what it receives.
- Ecosystem for custom fields. WPGraphQL for ACF exposes field groups with proper types.
Where the REST API shinesLink to this section
- Nothing to install. It's part of WordPress core and maintained with it.
- HTTP caching just works. REST requests are GET requests with URLs, so CDNs, browsers and Next.js's fetch cache handle them naturally.
- Easy to debug. Open the URL in a browser and read the JSON.
- Plugins already speak it. Yoast exposes
yoast_head_json, WooCommerce has its own REST API, and most plugins that care about headless add REST fields. - Smaller attack surface. One less plugin, and no query language that clients can use to build expensive requests.
Performance and cachingLink to this section
The performance difference between the two is rarely about the API itself; it's about requests per page and caching.
- With REST, use
_embedand_fields, and fetch independent data in parallel withPromise.all. Most pages need one to three requests. - With GraphQL, a single query per page is typical, but each query does more work on the server. Complex queries with many nested lists can be slow; WPGraphQL's query analyzer and depth limits help keep them in check.
- GraphQL requests are normally POSTs, which CDNs don't cache. On a Next.js frontend this matters less, because the server's fetch cache and ISR cache the rendered page. If you need CDN caching for the API itself, look at persisted queries sent as GET requests (provided by companion plugins such as WPGraphQL Smart Cache).
For both, on-demand revalidation from WordPress keeps pages fresh without short cache times. The headless WordPress architecture guide shows the webhook.
Custom fields and custom post typesLink to this section
REST: register post types and meta with show_in_rest => true, or add computed fields with register_rest_field():
register_rest_field( 'post', 'reading_minutes', array(
'get_callback' => fn ( $post ) => max( 1, (int) ceil( str_word_count( wp_strip_all_tags( get_post_field( 'post_content', $post['id'] ) ) ) / 220 ) ),
'schema' => array( 'type' => 'integer' ),
) );GraphQL: set show_in_graphql => true with graphql_single_name and graphql_plural_name on post types, and register fields with register_graphql_field(). ACF fields need the WPGraphQL for ACF extension.
Either way, decide your content model first; the custom post types guide covers that.
Previews and authenticationLink to this section
Both support authenticated requests for drafts. REST works with Application Passwords out of the box. WPGraphQL works with Application Passwords too, or with a JWT plugin. The draft previews guide builds the Next.js side with Draft Mode; it uses REST, but the same route works with a GraphQL query.
Security considerationsLink to this section
- REST: disable endpoints you don't use (for example the users endpoint, which lists author names) and keep authenticated requests on the server.
- GraphQL: turn off public introspection in production if you don't need it, set query depth and complexity limits, and remember that anything in the schema is queryable by anyone unless you restrict it.
- Both: never expose credentials in
NEXT_PUBLIC_variables, and sendnoindexfrom the WordPress host so the API site doesn't compete with the frontend in search.
A decision guideLink to this section
| Situation | Choose |
|---|---|
| Blog, marketing site, docs | REST API |
| Pages built from many nested, related objects | WPGraphQL |
| Heavy ACF use with complex field groups | WPGraphQL (with WPGraphQL for ACF) |
| You need CDN caching of API responses with minimal setup | REST API |
| Team already uses GraphQL elsewhere | WPGraphQL |
| WooCommerce storefront | WooCommerce REST or Store API |
| Minimal plugins, long-term low maintenance | REST API |
Keep a mapping layer either wayLink to this section
Whichever you pick, don't let raw API responses leak into components. Map them to your own types in one module:
export type Post = { slug: string; title: string; html: string; date: string; image: { url: string; alt: string } | null };
export function mapRestPost(raw: any): Post {
const media = raw._embedded?.["wp:featuredmedia"]?.[0];
return {
slug: raw.slug,
title: raw.title.rendered,
html: raw.content.rendered,
date: raw.date_gmt + "Z",
image: media ? { url: media.source_url, alt: media.alt_text ?? "" } : null,
};
}With a mapping layer, switching from REST to GraphQL later means rewriting one file, not every component.
Migrating between them laterLink to this section
The choice isn't permanent, and a clean data layer makes switching cheap. Teams most often move from REST to WPGraphQL when pages start needing data from several endpoints, or from WPGraphQL to REST when they want to drop a plugin or need custom endpoints that don't fit the graph.
A safe migration looks like this:
- Keep the mapping layer as the contract. Components use your own
Post,PageandProducttypes, never raw API shapes. Only the fetch functions change. - Move one page type at a time. Start with the simplest, often blog posts, and switch its fetcher. Compare the mapped output from both APIs in a test or a script before deploying.
- Run both in parallel briefly. A feature flag or environment variable that chooses the source lets you switch back instantly if something is missing in production.
- Watch caching. Revalidation webhooks, cache tags and CDN rules are often tied to URL patterns of the old API. Update them together with the fetchers.
- Remove the old code once every page type has moved and logs show no requests to the old endpoints.
Because the mapping layer hides the difference, a migration like this is mostly invisible to the rest of the frontend, which is the strongest argument for writing that layer from the start.
Mixing bothLink to this section
Mixing is also fine. Many production sites read content through WPGraphQL but use a few custom REST endpoints for actions such as form submissions or search, where a simple, cacheable URL is easier to secure and rate-limit. Choose per use case rather than per project.
Common problemsLink to this section
- REST returns too much data. Add
_fields. When combined with_embed, include_linksand_embeddedin the list or the embeds disappear. - GraphQL returns
nullfor a field. The field isn't exposed to the schema, or the requesting user can't see it. Checkshow_in_graphqland authentication. - Pagination totals missing. REST returns them in
X-WP-TotalandX-WP-TotalPagesheaders. GraphQL uses cursor-basedpageInfo, so page-number pagination needs extra work. - Slow GraphQL queries. Reduce nesting and list sizes, and use the query analyzer to find expensive fields.
Frequently asked questionsLink to this section
Can I use both?Link to this section
Yes. Some teams use REST for simple lists and GraphQL for complex pages. It adds two things to maintain, so only do it with a clear reason.
Is WPGraphQL officially supported?Link to this section
It's a widely used community plugin with an active team behind it and is listed in the WordPress plugin directory. It isn't part of WordPress core, so keep it updated like any other plugin.
Does the choice affect SEO?Link to this section
No. SEO depends on the HTML the frontend renders, its metadata and its URLs. Both data layers can provide everything a page needs.
Which is better for a mobile app?Link to this section
Either. GraphQL's exact field selection can reduce payload sizes on slow mobile networks; REST is simpler to cache and debug.
Can I switch from REST to WPGraphQL later?Link to this section
Yes. If components only use your own mapped types, switching is mostly a matter of rewriting the fetch functions one page type at a time, as described above. Without a mapping layer, the change touches every component that reads API data, which is why it's worth adding even on small projects.
Planning a headless build? See the production architecture guide or get in touch.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.