Headless WordPress with GraphQL
The WPGraphQL plugin adds a /graphql endpoint to WordPress. Instead of several REST requests, the
front end sends one query that names exactly the fields it wants, across related content (a book, its
genres, its cover image), and gets back exactly that shape. The schema is typed, so tools can check
queries and generate TypeScript types.
Choose GraphQL when a page needs many related things at once, or the team already uses GraphQL (Gatsby, Faust.js, Apollo). Stay with REST for simple sites: it needs no plugin and caches more easily.
This page sets up the same Book post type as the other guides, two ways.
1. Before you start
- A WordPress site with pretty permalinks (Settings → Permalinks: anything but Plain).
- The Book post type, Genre taxonomy and Book Details fields from step 2 of JavaScript + ACF or JavaScript + Unyson+.
- Plugins → Add New → WPGraphQL: install and activate. It adds a GraphQL menu with a query editor (GraphiQL IDE), handy for trying the queries below.
2a. With ACF and CPT UI
- Install and activate WPGraphQL for ACF as well.
- CPT UI → Edit Post Types → Book: set Show in GraphQL to True, GraphQL single name
book, GraphQL plural namebooks. Save. - CPT UI → Edit Taxonomies → Genre: Show in GraphQL True, names
genre/genres. Save. - ACF → Field Groups → Book Details → Settings → GraphQL: turn on Show in GraphQL and set the
GraphQL Type Name / field name to
bookDetails. Save.
Query:
{
books(first: 20) {
nodes {
slug
title
genres { nodes { name } }
bookDetails {
subtitle
bookAuthor
pages
buyUrl
coverImage { node { sourceUrl altText mediaDetails { width height } } }
}
}
}
}
Response (one book shown):
{
"slug": "the-quiet-garden",
"title": "The Quiet Garden",
"genres": { "nodes": [{ "name": "Fiction" }] },
"bookDetails": {
"subtitle": "A year among the weeds",
"bookAuthor": "Sam Rivera",
"pages": 248,
"buyUrl": "https://example.com/books/the-quiet-garden",
"coverImage": { "node": { "sourceUrl": "https://your-site.com/…/quiet-garden-cover.jpg", "altText": "The Quiet Garden cover", "mediaDetails": { "width": 600, "height": 900 } } }
}
}
WPGraphQL for ACF renames fields to camelCase (book_author → bookAuthor), and pages is a real
number here (unlike REST with acf_format=standard).
2b. With Unyson+
Unyson+ has no GraphQL switch yet, so this is a small file of code: the Post Types extension's
fw_ext_post_types_args and fw_ext_post_types_taxonomy_args filters put the type and taxonomy in the
schema, and register_graphql_fields() adds the Book Details values, each read with
fw_get_db_post_option(). The cover is resolved to its URL, alt text and size on the way.
Put the file in your child theme's inc/includes/ folder; the Unyson+ theme loads it automatically.
It is part of the child-theme example below (open inc/includes/headless-books-graphql.php):
GraphQL (WPGraphQL): puts the post type and taxonomy in the schema through the Post Types filters, and adds the Book Details fields with register_graphql_fields().
<?php
/**
* Books in GraphQL (WPGraphQL), for post types and fields made with the
* Unyson+ Post Types and Custom Fields extensions.
*
* Needs the WPGraphQL plugin. Does nothing without it.
*/
if ( ! defined( 'FW' ) ) {
die( 'Forbidden' );
}
// 1. Put the post type and the taxonomy in the GraphQL schema.
add_filter( 'fw_ext_post_types_args', 'headless_books_graphql_post_type', 10, 2 );
add_filter( 'fw_ext_post_types_taxonomy_args', 'headless_books_graphql_taxonomy', 10, 2 );
function headless_books_graphql_post_type( $args, $slug ) {
if ( 'book' === $slug ) {
$args['show_in_graphql'] = true;
$args['graphql_single_name'] = 'book';
$args['graphql_plural_name'] = 'books';
}
return $args;
}
function headless_books_graphql_taxonomy( $args, $slug ) {
if ( 'genre' === $slug ) {
$args['show_in_graphql'] = true;
$args['graphql_single_name'] = 'genre';
$args['graphql_plural_name'] = 'genres';
}
return $args;
}
// 2. Add the Book Details fields to the Book type.
add_action( 'graphql_register_types', 'headless_books_graphql_fields' );
function headless_books_graphql_fields() {
register_graphql_object_type( 'BookCover', array(
'description' => __( 'A book cover image.', 'headless-books' ),
'fields' => array(
'url' => array( 'type' => 'String' ),
'alt' => array( 'type' => 'String' ),
'width' => array( 'type' => 'Int' ),
'height' => array( 'type' => 'Int' ),
),
) );
// Each field reads its value with fw_get_db_post_option(), as a template would.
$value = function ( $name ) {
return function ( $post ) use ( $name ) {
$v = fw_get_db_post_option( $post->databaseId, $name, '' );
return '' === $v ? null : $v;
};
};
register_graphql_fields( 'Book', array(
'subtitle' => array( 'type' => 'String', 'resolve' => $value( 'subtitle' ) ),
'bookAuthor' => array( 'type' => 'String', 'resolve' => $value( 'book_author' ) ),
'pages' => array( 'type' => 'Int', 'resolve' => $value( 'pages' ) ),
'buyUrl' => array( 'type' => 'String', 'resolve' => $value( 'buy_url' ) ),
'cover' => array(
'type' => 'BookCover',
'resolve' => function ( $post ) {
$stored = fw_get_db_post_option( $post->databaseId, 'cover_image' );
$id = is_array( $stored ) && ! empty( $stored['attachment_id'] ) ? (int) $stored['attachment_id'] : 0;
$src = $id ? wp_get_attachment_image_src( $id, 'large' ) : false;
if ( ! $src ) {
return null;
}
return array(
'url' => $src[0],
'alt' => (string) get_post_meta( $id, '_wp_attachment_image_alt', true ),
'width' => (int) $src[1],
'height' => (int) $src[2],
);
},
),
) );
}
Query:
{
books(first: 20) {
nodes {
slug
title
genres { nodes { name } }
subtitle
bookAuthor
pages
buyUrl
cover { url alt width height }
}
}
}
Response (one book shown):
{
"slug": "the-quiet-garden",
"title": "The Quiet Garden",
"genres": { "nodes": [{ "name": "Fiction" }] },
"subtitle": "A year among the weeds",
"bookAuthor": "Sam Rivera",
"pages": 248,
"buyUrl": "https://example.com/books/the-quiet-garden",
"cover": { "url": "https://your-site.com/…/quiet-garden-cover.jpg", "alt": "The Quiet Garden cover", "width": 600, "height": 900 }
}
Here the fields sit directly on Book, because the code registers them there. A built-in GraphQL
option for Custom Fields is under consideration on the Headless extension roadmap.
3. Call it from the front end
GraphQL is one POST to /graphql with the query as JSON. This works from the browser, Node.js or
Next.js server components alike:
const query = `{ books(first: 20) { nodes { slug title bookAuthor pages cover { url alt } } } }`;
const res = await fetch('https://your-site.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
const { data, errors } = await res.json();
if (errors) throw new Error(errors[0].message);
console.log(data.books.nodes);
The front-end pages in the other guides work the same way: replace the REST calls in wordpress.js
(or lib/wordpress.ts) with this fetch, and read data.books.nodes instead of the REST array.
Troubleshooting
| What you see | Fix |
|---|---|
Cannot query field "books" on type "RootQuery" | The post type is not in GraphQL: Show in GraphQL (CPT UI) or the filter (Unyson+), with the names book / books |
Cannot query field "bookDetails" (ACF) | WPGraphQL for ACF is not active, or the field group's Show in GraphQL is off |
Cannot query field "subtitle" (Unyson+) | The headless-books-graphql.php file is not loaded: check it is in the child theme's inc/includes/ and the child theme is active |
/graphql shows a WordPress 404 | Permalinks are on Plain, or WPGraphQL is not active |
Fields come back null | The book has no value for them, or a field name in the code differs from the one in Custom Fields |
These setups were tested on two sites: one with CPT UI, ACF, WPGraphQL and WPGraphQL for ACF, and one with Unyson+ and WPGraphQL running the file above.