Custom REST endpoints
The built-in endpoints (/wp/v2/book) return WordPress's own shape: titles under title.rendered, genres
as IDs, fields under acf or unysonplus_fields. That is fine, but the front end ends up making extra
requests and reshaping data.
A custom endpoint is your own address, registered with register_rest_route(), that returns exactly
what a page needs in one request. Here, /wp-json/books/v1/books returns every book with its genre names
and a ready-to-use cover:
{
"slug": "the-quiet-garden",
"title": "The Quiet Garden",
"subtitle": "A year among the weeds",
"author": "Sam Rivera",
"pages": 248,
"genres": ["Fiction"],
"cover": { "url": "https://your-site.com/…/quiet-garden-cover.jpg", "alt": "The Quiet Garden cover", "width": 600, "height": 900 },
"buyUrl": "https://example.com/books/the-quiet-garden",
"content": "<p>A short novel about a year spent restoring an old walled garden.</p>"
}
and /wp-json/books/v1/books/<slug> returns one book (or a 404).
Choose a custom endpoint when you want a stable, front-end-friendly contract, fewer requests, or data the core endpoints do not have (computed values, several post types combined). The trade-off: you maintain the code, and changes to the data need a code change.
Before you start
The Book post type, Genre taxonomy and Book Details fields from step 2 of JavaScript + ACF or JavaScript + Unyson+. Pretty permalinks on. The endpoint does not need the fields' Show in REST API switch: it reads them itself.
With ACF: a one-file plugin
An ACF site can run any theme, so the endpoint is a tiny plugin. Download it, put books-endpoint.php in
wp-content/plugins/books-endpoint/ (or upload it as a zip under Plugins → Add New → Upload Plugin), and
activate Books Endpoint. Fields are read with ACF's get_field(); the cover uses the Image field's
Image Array return format.
A one-file plugin: the custom endpoint /wp-json/books/v1/books, reading the ACF fields with get_field().
<?php
/**
* Plugin Name: Books Endpoint
* Description: A custom REST endpoint for the Book post type (CPT UI) and its ACF fields: /wp-json/books/v1/books.
* Version: 1.0.0
* Requires Plugins: advanced-custom-fields
*
* One request returns everything a book page needs, in the shape the front end
* wants: genre names instead of IDs, the cover with its size and alt text, and
* no WordPress-specific wrappers such as title.rendered.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'rest_api_init', 'books_endpoint_register' );
function books_endpoint_register() {
register_rest_route( 'books/v1', '/books', array(
'methods' => 'GET',
'callback' => 'books_endpoint_list',
'permission_callback' => '__return_true', // public, read-only
) );
register_rest_route( 'books/v1', '/books/(?P<slug>[a-z0-9-]+)', array(
'methods' => 'GET',
'callback' => 'books_endpoint_single',
'permission_callback' => '__return_true',
) );
}
function books_endpoint_list() {
$posts = get_posts( array( 'post_type' => 'book', 'post_status' => 'publish', 'numberposts' => 100 ) );
return rest_ensure_response( array_map( 'books_endpoint_shape', $posts ) );
}
function books_endpoint_single( WP_REST_Request $request ) {
$posts = get_posts( array( 'post_type' => 'book', 'post_status' => 'publish', 'name' => $request['slug'], 'numberposts' => 1 ) );
if ( ! $posts ) {
return new WP_Error( 'book_not_found', 'No book with that slug.', array( 'status' => 404 ) );
}
return rest_ensure_response( books_endpoint_shape( $posts[0] ) );
}
/**
* One book, the way the front end wants it.
*/
function books_endpoint_shape( WP_Post $post ) {
$id = $post->ID;
$cover = get_field( 'cover_image', $id ); // Return Format: Image Array
return array(
'slug' => $post->post_name,
'title' => get_the_title( $post ),
'subtitle' => (string) get_field( 'subtitle', $id ),
'author' => (string) get_field( 'book_author', $id ),
'pages' => (int) get_field( 'pages', $id ),
'genres' => wp_get_post_terms( $id, 'genre', array( 'fields' => 'names' ) ),
'cover' => $cover ? array(
'url' => $cover['sizes']['large'] ?? $cover['url'],
'alt' => (string) $cover['alt'],
'width' => (int) ( $cover['sizes']['large-width'] ?? $cover['width'] ),
'height' => (int) ( $cover['sizes']['large-height'] ?? $cover['height'] ),
) : null,
'buyUrl' => esc_url_raw( (string) get_field( 'buy_url', $id ) ),
'content' => apply_filters( 'the_content', $post->post_content ),
);
}
With Unyson+: a file in the child theme
With the Unyson+ theme, put headless-books-endpoint.php in your child theme's inc/includes/ folder; the
parent theme loads it automatically (no require needed). Fields are read with
fw_get_db_post_option(). It is part of the child-theme example:
A custom REST endpoint, /wp-json/books/v1/books: one request, exactly the shape the front end wants (genre names, cover with size and alt).
<?php
/**
* A custom REST endpoint: /wp-json/books/v1/books (and /books/<slug>).
*
* One request returns everything a book page needs, in the shape the front end
* wants: genre names instead of IDs, the cover with its size and alt text, and
* no WordPress-specific wrappers such as title.rendered.
*/
if ( ! defined( 'FW' ) ) {
die( 'Forbidden' );
}
add_action( 'rest_api_init', 'headless_books_register_endpoint' );
function headless_books_register_endpoint() {
register_rest_route( 'books/v1', '/books', array(
'methods' => 'GET',
'callback' => 'headless_books_endpoint_list',
'permission_callback' => '__return_true', // public, read-only
) );
register_rest_route( 'books/v1', '/books/(?P<slug>[a-z0-9-]+)', array(
'methods' => 'GET',
'callback' => 'headless_books_endpoint_single',
'permission_callback' => '__return_true',
) );
}
function headless_books_endpoint_list() {
$posts = get_posts( array( 'post_type' => 'book', 'post_status' => 'publish', 'numberposts' => 100 ) );
return rest_ensure_response( array_map( 'headless_books_endpoint_shape', $posts ) );
}
function headless_books_endpoint_single( WP_REST_Request $request ) {
$posts = get_posts( array( 'post_type' => 'book', 'post_status' => 'publish', 'name' => $request['slug'], 'numberposts' => 1 ) );
if ( ! $posts ) {
return new WP_Error( 'book_not_found', __( 'No book with that slug.', 'headless-books' ), array( 'status' => 404 ) );
}
return rest_ensure_response( headless_books_endpoint_shape( $posts[0] ) );
}
/**
* One book, the way the front end wants it.
*/
function headless_books_endpoint_shape( WP_Post $post ) {
$id = $post->ID;
$cover = fw_get_db_post_option( $id, 'cover_image' );
$image = is_array( $cover ) && ! empty( $cover['attachment_id'] ) ? (int) $cover['attachment_id'] : 0;
$src = $image ? wp_get_attachment_image_src( $image, 'large' ) : false;
return array(
'slug' => $post->post_name,
'title' => get_the_title( $post ),
'subtitle' => (string) fw_get_db_post_option( $id, 'subtitle', '' ),
'author' => (string) fw_get_db_post_option( $id, 'book_author', '' ),
'pages' => (int) fw_get_db_post_option( $id, 'pages', 0 ),
'genres' => wp_get_post_terms( $id, 'genre', array( 'fields' => 'names' ) ),
'cover' => $src ? array(
'url' => $src[0],
'alt' => (string) get_post_meta( $image, '_wp_attachment_image_alt', true ),
'width' => (int) $src[1],
'height' => (int) $src[2],
) : null,
'buyUrl' => esc_url_raw( (string) fw_get_db_post_option( $id, 'buy_url', '' ) ),
'content' => apply_filters( 'the_content', $post->post_content ),
);
}
The two versions return identical JSON, so the front end does not care which one the site runs.
Check it
Open https://your-site.com/wp-json/books/v1/books in a browser: you should see the list. Then
https://your-site.com/wp-json/books/v1/books/the-quiet-garden (with one of your slugs) for a single
book, and any made-up slug for the 404.
Use it from the front end
const books = await (await fetch('https://your-site.com/wp-json/books/v1/books')).json();
// books[0].title, books[0].genres, books[0].cover.url — no reshaping needed
Good practice
- Keep a version in the address (
books/v1). When the shape must change, addbooks/v2and keepv1until the front end has moved. - Public endpoints get
'permission_callback' => '__return_true', and only ever return published content. Anything private needs a real permission check (current_user_can()). - Escape what you output as you would in a template: URLs through
esc_url_raw(), content throughthe_contentfilters. - Cache heavy endpoints in a transient, and clear it when a book is saved (
save_post_book).
Troubleshooting
| What you see | Fix |
|---|---|
rest_no_route | The plugin or file is not loaded (plugin inactive, file outside inc/includes/, or a different child theme active), or permalinks are on Plain |
| Fields are empty | A field name in the code differs from the one in ACF / Custom Fields |
content is empty on a Unyson+ site | Update Unyson+: Page Builder versions before 1.7.20 returned empty content to custom endpoints |
cover is null (ACF) | The Image field's Return Format is not Image Array, or the book has no cover |
Both versions were tested side by side on an ACF + CPT UI site and a Unyson+ site, returning the same JSON.