Skip to main content

Headless WordPress with Next.js, ACF and CPT UI

The most common headless WordPress stack: Custom Post Type UI for the content type, Advanced Custom Fields (ACF) for the fields, and a Next.js front end that builds a page per post ahead of time and refreshes from WordPress in the background.

The example is a small book site: a list of books, and a page per book with its own title, description and cover as page metadata.

The same front end with Unyson+ instead of the two plugins: Next.js + Unyson+. Without a framework: JavaScript + ACF.

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.
  • The free plugins Custom Post Type UI and Advanced Custom Fields (step 2 installs them).
  • 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 Install the two plugins​

Plugins → Add New, search for and install Custom Post Type UI and Advanced Custom Fields, then Activate both.

2.3 Create the Book post type​

CPT UI → Add/Edit Post Types:

  1. Post Type Slug: book. Plural Label: Books. Singular Label: Book.
  2. Scroll to Settings and check that Show in REST API is True (the default in current versions). Leave REST API base slug empty, so the address is /wp-json/wp/v2/book.
  3. Under Supports, keep Title, Editor and Featured Image.
  4. Add Post Type.

2.4 Create the Genre taxonomy​

CPT UI → Add/Edit Taxonomies:

  1. Taxonomy Slug: genre. Plural Label: Genres. Singular Label: Genre.
  2. Attach to Post Type: tick Books.
  3. Under Settings: Hierarchical True (so genres work like categories), Show in REST API True.
  4. Add Taxonomy.

2.5 Create the fields​

ACF → Field Groups → Add New:

  1. Field Group Title: Book Details.
  2. Add the fields (+ Add Field for each):
Field LabelField NameField TypeSetting to change
SubtitlesubtitleText
Authorbook_authorText
PagespagesNumber
Cover imagecover_imageImageReturn Format: Image Array
Buy linkbuy_urlURL

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

  1. Settings → Location Rules: Post Type is equal to Book.
  2. Settings → Group Settings: turn on Show in REST API. Without it the acf object never appears in the API.
  3. Save Changes.

2.6 Add some content​

Genres → Add New Genre: 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, 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,title,acf&acf_format=standard

You should see your books as JSON, each with an acf object holding the five fields and the cover as an object with a url. If you see something else, go to Troubleshooting.

What the API returns​

The fields arrive under acf. With acf_format=standard in the request, ACF returns each field in its Return Format, so the cover is an object with its URL, width, height, alt text and image sizes (without it, just the attachment ID). One catch: with acf_format=standard, Number fields come back as strings ("pages": "248"), so the types allow both and the pages convert with Number().

"acf": {
"subtitle": "A year among the weeds",
"book_author": "Sam Rivera",
"pages": "248",
"cover_image": { "id": 3659, "url": "https://your-site.com/…/quiet-garden-cover.jpg", "alt": "The Quiet Garden cover", "width": 600, "height": 900, "sizes": { "…": "…" } },
"buy_url": "https://example.com/books/the-quiet-garden"
}

3. The code​

Click any file to open it, or download the whole project.

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

The one place that talks to WordPress: types for the ACF fields, a fetch helper with revalidation, and one function per query. acf_format=standard returns the cover as an image object.

// 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 ACF Image field with Return Format "Image Array", as sent with acf_format=standard.
export type AcfImage = {
id: number;
url: string;
alt: string;
width: number;
height: number;
sizes: Record<string, string | number>;
};

export type Book = {
id: number;
slug: string;
title: { rendered: string };
excerpt: { rendered: string };
content: { rendered: string };
genre: number[];
// Filled by the "Book Details" ACF field group (Show in REST API on).
acf: {
subtitle?: string;
book_author?: string;
// ACF sends numbers as strings when acf_format=standard: convert with Number().
pages?: string | number;
cover_image?: AcfImage | false;
buy_url?: string;
};
};

export type Genre = { id: number; name: string; slug: string };

// Ask only for what the pages use. acf_format=standard makes ACF return each field
// in its Return Format: the cover as an object with its URL, size and alt text.
const BOOK_QUERY = '_fields=id,slug,title,excerpt,content,genre,acf&acf_format=standard';

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?${BOOK_QUERY}&per_page=100`);
}

export async function getBook(slug: string) {
const books = await wp<Book[]>(`/wp/v2/book?slug=${encodeURIComponent(slug)}&${BOOK_QUERY}`);
return books[0] ?? null;
}

export function getGenres() {
return wp<Genre[]>('/wp/v2/genre?_fields=id,name,slug&per_page=100');
}

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 describes the ACF fields as TypeScript types (including the image object), adds acf_format=standard to every book request, and has one function per query. Pages never build REST URLs themselves.
  • 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.
  • Metadata from ACF. generateMetadata uses the subtitle as the description and the cover, with its size and alt text, as the 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

Next.js or plain JavaScript?​

Next.js builds pages on the server, which suits full websites where search ranking and first load matter. For a small site, a widget, or to learn the API, JavaScript + ACF does the same in the browser with no build step.