Headless WordPress with Next.js and Unyson+
The same Next.js front end as Next.js + ACF, with one plugin instead of two: Unyson+'s Post Types extension registers the content type, and its Custom Fields extension adds the fields and puts them in the REST API. The front end builds a page per post ahead of time and refreshes from WordPress in the background.
Without a framework: JavaScript + Unyson+. The WordPress side in more detail, including a version defined in code: Headless with Unyson+.
1. Before you start
You need:
- A WordPress site you can log in to as an administrator: on any host, or on your own computer (LocalWP, XAMPP). It needs to be reachable from wherever the front end runs.
- Unyson+ installed and active (download
unysonplus.zipfrom the installation guide and upload it under Plugins → Add New → Upload Plugin). - Node.js 18.18 or newer and a terminal. The front end is a Next.js 15 project;
npm installfetches everything else.
About 15 minutes for WordPress, and 10 for the front end.
2. Set up WordPress
2.1 Turn on pretty permalinks
Go to Settings → Permalinks and choose anything except Plain (for example Post name), then
Save Changes. The REST API lives at /wp-json/, and that address only works with pretty permalinks.
(With Plain permalinks the same data is at /?rest_route=/wp/v2/book, which the examples do not use.)
2.2 Activate the two extensions
Unyson+ must be installed and active (Plugins). Then go to Unyson+ → Extensions, and Download (if needed) and Activate Post Types and Custom Fields. Both then appear in the Unyson+ menu.
2.3 Create the Book post type
Unyson+ → Post Types, then Add Post Type:
- General tab: Singular label Book, Plural label Books, Key / slug
book. Keep the Supports boxes Title, Editor and Featured image ticked. - Visibility tab: check that Block editor / REST is on (the default). Leave REST base empty,
so the address is
/wp-json/wp/v2/book. - URLs tab (optional): URL slug
books. - Close the popup.
2.4 Create the Genre taxonomy
Still on Unyson+ → Post Types, click Add Taxonomy:
- Singular Genre, Plural Genres, Key
genre, Post types: Book, Hierarchical on (so genres work like categories). - Visibility & URLs: check that Show in REST is on (the default).
- Close the popup, then Save Changes at the bottom of the page. The overview table at the top should now show both as Registered.
2.5 Create the fields
Unyson+ → Custom Fields → Add Field Group:
- Group title: Book Details. Show on post types: Book.
- Turn on Show in REST API ("Expose this group's field values under unysonplus_fields"). Without it the fields never appear in the API.
- Under Fields, Add Field for each:
| 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 |
The Field name is the key the API uses, so type it exactly as shown.
- Close the popup and Save Changes.
2.6 Add some content
Books → Genres: add one or two (Fiction, Non-fiction). Then Books → Add New for two or three books: a title, some text, a genre, and every Book Details field (the box below the editor), including a cover image. Publish each one (drafts are not in the public API).
2.7 Check the API
Open this address in your browser, with your own domain:
https://your-site.com/wp-json/wp/v2/book?_fields=id,slug,type,title,unysonplus_fields
You should see your books as JSON, each with a unysonplus_fields object holding the five fields. If you
see something else, go to Troubleshooting.
What the API returns
The fields arrive under unysonplus_fields, with no extra query parameter:
"unysonplus_fields": {
"subtitle": "A year among the weeds",
"book_author": "Sam Rivera",
"pages": 248,
"cover_image": { "attachment_id": 3630, "url": "//your-site.com/wp-content/uploads/2026/10/quiet-garden-cover.jpg" },
"buy_url": "https://example.com/books/the-quiet-garden"
}
pages is a number. The image URL starts with //, so absoluteUrl() in lib/wordpress.ts gives it
your site's scheme. Image sizes and alt text in the response are on the Headless extension
roadmap.
3. The code
Click any file to open it, or download the whole project.
The one place that talks to WordPress: types for the REST response, a fetch helper with revalidation, and one function per query.
// Every request to WordPress goes through this file, so the rest of the app never
// builds a REST URL by hand.
const WP_URL = process.env.WP_URL;
if (!WP_URL) {
throw new Error('Set WP_URL in .env.local, e.g. WP_URL=https://your-site.com');
}
// An Unyson+ Image field: the attachment ID and a protocol-relative URL.
export type WpImage = { attachment_id: number | ''; url: string };
export type Book = {
id: number;
slug: string;
title: { rendered: string };
excerpt: { rendered: string };
content: { rendered: string };
genre: number[];
// Filled by the "Book Details" field group (Custom Fields → Show in REST API).
unysonplus_fields: {
subtitle?: string;
book_author?: string;
pages?: number;
cover_image?: WpImage;
buy_url?: string;
};
};
export type Genre = { id: number; name: string; slug: string };
// Ask only for what the pages use: smaller responses, faster builds.
// "type" is included because older Custom Fields versions need it to fill unysonplus_fields.
const BOOK_FIELDS = 'id,slug,type,title,excerpt,content,genre,unysonplus_fields';
async function wp<T>(path: string): Promise<T> {
const res = await fetch(`${WP_URL}/wp-json${path}`, {
// Re-fetch at most every 5 minutes (incremental static regeneration).
next: { revalidate: 300 },
});
if (!res.ok) {
throw new Error(`WordPress returned ${res.status} for ${path}`);
}
return res.json() as Promise<T>;
}
export function getBooks() {
return wp<Book[]>(`/wp/v2/book?_fields=${BOOK_FIELDS}&per_page=100`);
}
export async function getBook(slug: string) {
const books = await wp<Book[]>(
`/wp/v2/book?slug=${encodeURIComponent(slug)}&_fields=${BOOK_FIELDS}`
);
return books[0] ?? null;
}
export function getGenres() {
return wp<Genre[]>('/wp/v2/genre?_fields=id,name,slug&per_page=100');
}
// Image URLs come back as "//host/path"; give them the WordPress site's own scheme.
export function absoluteUrl(url?: string) {
if (!url) return undefined;
return url.startsWith('//') ? `${new URL(WP_URL!).protocol}${url}` : url;
}
4. Set up the front end
4.1 What you need
Node.js 18.18 or newer (check with node -v; get it from nodejs.org). The example uses Next.js 15
with the App Router and TypeScript; npm installs them for you.
4.2 Get the code
Click Download on the code window above and unzip it. Open a terminal in that folder and install:
npm install
4.3 Point it at your site
Copy .env.example to .env.local and set your WordPress address, with no slash at the end:
WP_URL=https://your-site.com
.env.local stays on your computer (it is not uploaded with the code), so each place the site runs sets
its own WP_URL.
4.4 Run it on your computer
npm run dev
Open http://localhost:3000/books. You should see your books with their covers, and each one opens its own
page at /books/<slug>.
To try the production version, which builds every book page ahead of time:
npm run build
npm run start
The build fails with "WordPress returned 404" or a fetch error if WP_URL is wrong or the API is not
reachable; fix that first (see Troubleshooting).
4.5 Put it online
Next.js needs a host that runs it. The simplest is Vercel (from the makers of Next.js); Netlify and any server with Node.js also work.
- Put the folder in a Git repository (GitHub, GitLab or Bitbucket) and import it on the host.
- In the host's project settings, add the environment variable
WP_URL=https://your-site.com. - Deploy. The host runs
npm run build, which fetches every book from WordPress and builds its page.
After that:
- New and edited books appear within five minutes, without a new deploy (
revalidate: 300inlib/wordpress.ts). Lower the number for faster updates; a rebuild webhook is on the roadmap. - Use HTTPS for WordPress. The front end runs on HTTPS, and browsers block
http://images on anhttps://page. - No CORS setup is needed. The pages are built on the server, and WordPress lets other sites read its public REST API anyway.
How it fits together
- One file talks to WordPress.
lib/wordpress.tsknows the API's address, describes the Unyson+ fields as TypeScript types, and has one function per query. Pages never build REST URLs themselves. - Ask only for the fields you use with
?_fields=, so responses stay small. - Pages are server components. They
awaitthose functions, so the HTML that reaches the browser (and search engines) already holds the content. - Built ahead of time, refreshed in the background.
generateStaticParamsbuilds every book page;revalidate: 300fetches fresh data at most every five minutes. To update the moment an editor publishes, call a revalidation route from a WordPress webhook (on the roadmap). - Metadata from WordPress.
generateMetadataturns the book's title, subtitle and cover into the page title, description and sharing image.
Troubleshooting
| What you see | Why | Fix |
|---|---|---|
A WordPress "Page not found" page, or a 404, at /wp-json/… | Permalinks are set to Plain | Settings → Permalinks: choose Post name, Save Changes |
{"code":"rest_no_route", …} | The post type is not in the REST API, or its slug is not book | Check Show in REST API on the post type, and its slug. Then Settings → Permalinks → Save Changes to refresh the addresses |
[] (an empty list) | No published books | Publish at least one book; drafts are not public |
No acf object | The field group's Show in REST API is off | ACF → Field Groups → Book Details → Group Settings → Show in REST API on |
"cover_image": 3659 (a number) with ACF | acf_format=standard is missing from the request | Keep acf_format=standard in the request, and the Image field's Return Format on Image Array |
"acf": [] or fields missing | Field names differ from the code | Field names must be exactly subtitle, book_author, pages, cover_image, buy_url |
No unysonplus_fields object | The field group's Show in REST API is off, or Custom Fields is not active | Unyson+ → Custom Fields → Book Details → Show in REST API on, then Save Changes |
"unysonplus_fields": [] | The request's _fields leaves out type (needed by Custom Fields before 0.1.21) | Keep type in _fields, as the examples do, or update Unyson+ |
| Books show but no covers | The site is on http:// and the front end on https:// (mixed content), or no cover was set | Put WordPress on HTTPS; check each book has a cover |
| The page stays on "Loading…" (plain JavaScript) | Opened from the disk, or js/config.js has the wrong address | Serve the folder with a web server; check WP_URL; read the error in the browser console (F12) |
WordPress returned 401 or 403 | A security plugin or host rule blocks the REST API for visitors | Allow public GET requests to /wp-json/wp/v2/ in that plugin or rule |
Next.js: Set WP_URL in .env.local | The environment variable is missing | Create .env.local locally, or add WP_URL in the host's settings |
| Next.js: a new book does not appear | The page is cached for up to five minutes | Wait, or lower revalidate in lib/wordpress.ts |
What changes from the ACF version
| ACF + CPT UI | Unyson+ | |
|---|---|---|
| Plugins | Two | One (two extensions) |
| Fields in the response | acf | unysonplus_fields |
| Extra query parameter | acf_format=standard | None |
pages | String with acf_format=standard | Number |
| Cover image | Object with URL, sizes, alt | ID and // URL (sizes and alt planned) |
Everything else (routing, revalidation, metadata) is identical: compare the two lib/wordpress.ts files.