Skip to main content

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.zip from 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 install fetches everything else.

About 15 minutes for WordPress, and 10 for the front end.

2. Set up WordPress​

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:

  1. General tab: Singular label Book, Plural label Books, Key / slug book. Keep the Supports boxes Title, Editor and Featured image ticked.
  2. Visibility tab: check that Block editor / REST is on (the default). Leave REST base empty, so the address is /wp-json/wp/v2/book.
  3. URLs tab (optional): URL slug books.
  4. Close the popup.

2.4 Create the Genre taxonomy​

Still on Unyson+ → Post Types, click Add Taxonomy:

  1. Singular Genre, Plural Genres, Key genre, Post types: Book, Hierarchical on (so genres work like categories).
  2. Visibility & URLs: check that Show in REST is on (the default).
  3. 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:

  1. Group title: Book Details. Show on post types: Book.
  2. Turn on Show in REST API ("Expose this group's field values under unysonplus_fields"). Without it the fields never appear in the API.
  3. Under Fields, Add Field for each:
Field labelField nameField type
SubtitlesubtitleText
Authorbook_authorText
PagespagesNumber
Cover imagecover_imageImage
Buy linkbuy_urlURL

The Field name is the key the API uses, so type it exactly as shown.

  1. 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.

headless-books (Next.js + Unyson+)Download
lib/wordpress.ts

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.

  1. Put the folder in a Git repository (GitHub, GitLab or Bitbucket) and import it on the host.
  2. In the host's project settings, add the environment variable WP_URL = https://your-site.com.
  3. 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: 300 in lib/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 an https:// 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.ts knows 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 await those functions, so the HTML that reaches the browser (and search engines) already holds the content.
  • Built ahead of time, refreshed in the background. generateStaticParams builds every book page; revalidate: 300 fetches 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. generateMetadata turns the book's title, subtitle and cover into the page title, description and sharing image.

Troubleshooting​

What you seeWhyFix
A WordPress "Page not found" page, or a 404, at /wp-json/…Permalinks are set to PlainSettings → Permalinks: choose Post name, Save Changes
{"code":"rest_no_route", …}The post type is not in the REST API, or its slug is not bookCheck 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 booksPublish at least one book; drafts are not public
No acf objectThe field group's Show in REST API is offACF → Field Groups → Book Details → Group Settings → Show in REST API on
"cover_image": 3659 (a number) with ACFacf_format=standard is missing from the requestKeep acf_format=standard in the request, and the Image field's Return Format on Image Array
"acf": [] or fields missingField names differ from the codeField names must be exactly subtitle, book_author, pages, cover_image, buy_url
No unysonplus_fields objectThe field group's Show in REST API is off, or Custom Fields is not activeUnyson+ → 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 coversThe site is on http:// and the front end on https:// (mixed content), or no cover was setPut 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 addressServe the folder with a web server; check WP_URL; read the error in the browser console (F12)
WordPress returned 401 or 403A security plugin or host rule blocks the REST API for visitorsAllow public GET requests to /wp-json/wp/v2/ in that plugin or rule
Next.js: Set WP_URL in .env.localThe environment variable is missingCreate .env.local locally, or add WP_URL in the host's settings
Next.js: a new book does not appearThe page is cached for up to five minutesWait, or lower revalidate in lib/wordpress.ts

What changes from the ACF version​

ACF + CPT UIUnyson+
PluginsTwoOne (two extensions)
Fields in the responseacfunysonplus_fields
Extra query parameteracf_format=standardNone
pagesString with acf_format=standardNumber
Cover imageObject with URL, sizes, altID and // URL (sizes and alt planned)

Everything else (routing, revalidation, metadata) is identical: compare the two lib/wordpress.ts files.