Skip to main content

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.

books-endpoint (ACF plugin)Download
books-endpoint.php

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:

headless-books (child theme)Download
inc/includes/headless-books-endpoint.php

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, add books/v2 and keep v1 until 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 through the_content filters.
  • Cache heavy endpoints in a transient, and clear it when a book is saved (save_post_book).

Troubleshooting​

What you seeFix
rest_no_routeThe 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 emptyA field name in the code differs from the one in ACF / Custom Fields
content is empty on a Unyson+ siteUpdate 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.