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 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 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:
- Post Type Slug:
book. Plural Label: Books. Singular Label: Book. - 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. - Under Supports, keep Title, Editor and Featured Image.
- Add Post Type.
2.4 Create the Genre taxonomy
CPT UI → Add/Edit Taxonomies:
- Taxonomy Slug:
genre. Plural Label: Genres. Singular Label: Genre. - Attach to Post Type: tick Books.
- Under Settings: Hierarchical True (so genres work like categories), Show in REST API True.
- Add Taxonomy.
2.5 Create the fields
ACF → Field Groups → Add New:
- Field Group Title: Book Details.
- Add the fields (+ Add Field for each):
| Field Label | Field Name | Field Type | Setting to change |
|---|---|---|---|
| Subtitle | subtitle | Text | |
| Author | book_author | Text | |
| Pages | pages | Number | |
| Cover image | cover_image | Image | Return Format: Image Array |
| Buy link | buy_url | URL |
The Field Name is the key the API uses, so type it exactly as shown.
- Settings → Location Rules: Post Type is equal to Book.
- Settings → Group Settings: turn on Show in REST API. Without it the
acfobject never appears in the API. - 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.
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.
- 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.tsdescribes the ACF fields as TypeScript types (including the image object), addsacf_format=standardto every book request, and has one function per query. Pages never build REST URLs themselves. - 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. - Metadata from ACF.
generateMetadatauses the subtitle as the description and the cover, with its size and alt text, as the 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 |
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.