Headless WordPress with Unyson+
This tutorial builds the content side of a headless site using only Unyson+: a Book post type with a Genre taxonomy and five custom fields, available over the WordPress REST API, then fetched from a front end in plain JavaScript or Next.js. No ACF and no custom post types plugin are needed.
New to headless? Start with How headless WordPress works.
:::info What works today, and what is coming Everything on this page works with the current Post Types and Custom Fields extensions. The planned Headless extension adds image sizes and alt text, settings and menus endpoints, preview, rebuild webhooks and Page Builder content. See the roadmap. :::
1. Activate the two extensions
Go to Unyson+ → Extensions and activate Post Types and Custom Fields (download them first if they are not installed).
2. Create the post type and taxonomy
Go to Unyson+ → Post Types (Post Types documentation).
- In Post Types, click Add Post Type: Singular Book, Plural Books, Key
book. On the Visibility tab, leave Block editor / REST on (it is on by default). On the URLs tab you can set the URL slug tobooks. - In Taxonomies, click Add Taxonomy: Singular Genre, Plural Genres, Key
genre, attached to Book. Leave Show in REST on. - Save.
The type is now in the API at /wp-json/wp/v2/book, and the taxonomy at /wp-json/wp/v2/genre.
For common content (team members, events, testimonials, FAQs and more), the Blueprints tab creates the post type, its taxonomies and a matching field group in one click.
3. Create the field group
Go to Unyson+ → Custom Fields and click Add Field Group (Custom Fields documentation).
- Group title: Book Details. Show on post types: Book.
- Turn on Show in REST API. Without it, the values stay out of the API.
- Add the fields:
| Field label | Field name | Field type |
|---|---|---|
| Subtitle | subtitle | Text |
| Author | book_author | Text |
| Pages | pages | Number |
| Cover image | cover_image | Image |
| Buy link | buy_url | URL |
- Save, then add a book (Books → Add New) and fill in the fields.
4. Read it from the REST API
Request the books:
curl "https://your-site.com/wp-json/wp/v2/book?_fields=id,slug,title,genre,unysonplus_fields"
The field values come back in unysonplus_fields:
[
{
"id": 3629,
"slug": "the-quiet-garden",
"title": { "rendered": "The Quiet Garden" },
"genre": [258],
"unysonplus_fields": {
"subtitle": "A year among the weeds",
"book_author": "Sam Rivera",
"pages": 248,
"cover_image": {
"attachment_id": 3624,
"url": "//your-site.com/wp-content/uploads/2026/10/quiet-garden-cover.jpg"
},
"buy_url": "https://example.com/books/the-quiet-garden"
}
}
]
Things to know about the values:
- Each field returns its stored value shape. Text is a string, Number a number, and an Image is an
object with
attachment_idandurl. The URL starts with//(no scheme), so add your site's scheme in the front end. Other common shapes: a Gallery is an array of those image objects, a Repeater is an array of row objects, Checkboxes is an object of the ticked choices ({ "red": true }), and Related posts is an array of post IDs. - Taxonomies return term IDs (
"genre": [258]). Get the names from/wp-json/wp/v2/genre, or add_embedto the request. - The REST field is read-only: the API returns values but does not save them.
unysonplus_fieldsrequires Unyson+ Custom Fields 0.1.21 or later when you trim the response with_fields, as above. Earlier versions returned it empty unlesstypewas also requested.
5. Build the front end
The books are now one request away from any front end. Two complete, tested front ends for exactly this Book post type, with every file to browse and download:
- Plain JavaScript: two HTML pages and a few JavaScript modules that fetch the books in the browser. No framework, no build step.
- Next.js: an App Router site with a page per book, built ahead of time and refreshed from WordPress.
The same requests work from Nuxt, Astro, SvelteKit or a mobile app; only the fetching code changes.
Or define it all in code
Steps 2 and 3 can also be code in a child theme instead of admin screens. Use this route when the
definitions should live in version control, or ship with a theme you distribute. It is the same Book,
with one difference: the REST field is your own, so you choose its shape. Here the cover comes back
ready for an <img> tag, with its size and alt text.
Adds the field values to each book's REST response, and resolves the cover image to a URL, size and alt text.
<?php
/**
* Adds the Book Details values to the REST API as `book_fields`.
*
* Doing this in code lets you shape the output for the front end: here the
* cover comes back with its size and alt text instead of the stored
* { attachment_id, url } pair.
*/
if ( ! defined( 'FW' ) ) {
die( 'Forbidden' );
}
add_action( 'rest_api_init', 'headless_books_register_rest_fields' );
function headless_books_register_rest_fields() {
register_rest_field( 'book', 'book_fields', array(
'get_callback' => 'headless_books_rest_fields',
'schema' => array(
'description' => __( 'Book Details values.', 'headless-books' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
) );
}
/**
* @param array $post The post as prepared for the response.
*
* @return array
*/
function headless_books_rest_fields( $post ) {
$id = (int) $post['id'];
return array(
'subtitle' => (string) fw_get_db_post_option( $id, 'subtitle', '' ),
'book_author' => (string) fw_get_db_post_option( $id, 'book_author', '' ),
'pages' => (int) fw_get_db_post_option( $id, 'pages', 0 ),
'cover_image' => headless_books_rest_image( fw_get_db_post_option( $id, 'cover_image' ) ),
'buy_url' => esc_url_raw( (string) fw_get_db_post_option( $id, 'buy_url', '' ) ),
);
}
/**
* Turn a stored upload value into what a front end needs for an <img>.
*
* @param mixed $value { attachment_id, url } as saved by the upload option.
*
* @return array|null
*/
function headless_books_rest_image( $value ) {
$attachment_id = is_array( $value ) && ! empty( $value['attachment_id'] ) ? (int) $value['attachment_id'] : 0;
$src = $attachment_id ? wp_get_attachment_image_src( $attachment_id, 'large' ) : false;
if ( ! $src ) {
return null;
}
return array(
'id' => $attachment_id,
'url' => $src[0],
'width' => (int) $src[1],
'height' => (int) $src[2],
'alt' => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ),
);
}
How the files are loaded follows the Unyson+ theme conventions:
inc/includes/— the parent theme includes every PHP file in a child theme'sinc/includes/folder automatically, sofunctions.phpneeds norequirelines. Prefix the file names (headless-books-*) so they never share a name with one of the parent's.framework-customizations/theme/options/posts/book.php— Unyson+ shows these options on the Book edit screen and saves each value under its option id, whichfw_get_db_post_option()reads back.- Not straight in
inc/. A child file there named like a parent file (inc/post-types.php,inc/menus.php,inc/static.php,inc/hooks.php) replaces the parent's file instead of adding to it.
The response then carries book_fields:
{
"id": 3642,
"slug": "the-quiet-garden",
"title": { "rendered": "The Quiet Garden" },
"genre": [258],
"book_fields": {
"subtitle": "A year among the weeds",
"book_author": "Sam Rivera",
"pages": 248,
"cover_image": {
"id": 3624,
"url": "https://your-site.com/wp-content/uploads/2026/10/quiet-garden-cover.jpg",
"width": 1024,
"height": 1024,
"alt": "The Quiet Garden cover"
},
"buy_url": "https://example.com/books/the-quiet-garden"
}
}
If you use this route with the Next.js example, read book_fields instead of unysonplus_fields in
lib/wordpress.ts, and use the cover's url as it is (it already has its scheme).
Compared with ACF and a custom post types plugin
| ACF + CPT plugin | Unyson+ | |
|---|---|---|
| Post types and taxonomies in REST | Yes | Yes (on by default) |
| Custom fields in REST | Per field group | Per field group, under unysonplus_fields |
| Repeaters, galleries, relationships | ACF Pro | Included |
| Global settings in REST | ACF Pro options page | Coming in the Headless extension |
| Menus, preview, rebuild webhooks | Other plugins or custom code | Coming in the Headless extension |
| GraphQL | WPGraphQL for ACF | Being considered |
Current limits
- Images return the URL and ID only. For sizes and alt text, request the attachment from
/wp-json/wp/v2/media/<id>. The Headless extension will add them directly. - Fields on posts only. Field groups on taxonomy terms and users are not yet in the API.
- No settings or menus endpoints yet, and Page Builder content comes through only as
content.rendered, without the CSS it needs.
All of these are on the Headless roadmap.