Headless WordPress with JavaScript, ACF and CPT UI
The most common headless setup, without a framework: Custom Post Type UI registers the content type, Advanced Custom Fields (ACF) adds the fields, and two plain HTML pages fetch everything from the WordPress REST API in the browser. Nothing to install on the front end and no build step.
The example is a small book site: a list of books, and a page per book.
The same front end with Unyson+ instead of the two plugins: JavaScript + Unyson+. With Next.js: Next.js + 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).
- A text editor, and a way to serve a folder over HTTP for testing: Node.js (
npx serve) or Python (python -m http.server).
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
ACF puts the fields under acf on each post. By default an Image field comes back as just the
attachment ID. Add acf_format=standard to the request and ACF returns each field in its Return
Format instead, so the cover arrives as an object with its URL, size and alt text:
curl "https://your-site.com/wp-json/wp/v2/book?slug=the-quiet-garden&_fields=id,slug,genre,acf&acf_format=standard"
[
{
"id": 3660,
"slug": "the-quiet-garden",
"genre": [258],
"acf": {
"subtitle": "A year among the weeds",
"book_author": "Sam Rivera",
"pages": "248",
"cover_image": {
"id": 3659,
"url": "https://your-site.com/wp-content/uploads/2026/10/quiet-garden-cover.jpg",
"alt": "The Quiet Garden cover",
"width": 600,
"height": 900,
"sizes": { "medium": "https://your-site.com/…-200x300.jpg", "medium_large": "…" }
},
"buy_url": "https://example.com/books/the-quiet-garden"
}
}
]
The image object also carries the title, caption, file size and every image size; it is trimmed here.
Note that pages is a string ("248") with acf_format=standard, while it is a number without it,
so the code converts it with Number().
3. The code
Click any file to open it, or download the whole project.
The one place that talks to WordPress. Every book request asks for the acf fields with acf_format=standard, so the cover comes back as an object with its URL and alt text.
// Every request to WordPress goes through this file, so the pages never build a
// REST URL by hand.
import { WP_URL } from './config.js';
// Ask only for what the pages use: smaller responses, faster pages.
// acf_format=standard makes ACF return each field in its "Return Format": the
// cover image as an object with its URL, size and alt text instead of a bare ID.
const BOOK_QUERY = '_fields=id,slug,title,excerpt,content,genre,acf&acf_format=standard';
async function wp(path) {
const res = await fetch(`${WP_URL}/wp-json${path}`);
if (!res.ok) {
throw new Error(`WordPress returned ${res.status} for ${path}`);
}
return res.json();
}
export function getBooks() {
return wp(`/wp/v2/book?${BOOK_QUERY}&per_page=100`);
}
export async function getBook(slug) {
const books = await wp(`/wp/v2/book?slug=${encodeURIComponent(slug)}&${BOOK_QUERY}`);
return books[0] ?? null;
}
export function getGenres() {
return wp('/wp/v2/genre?_fields=id,name,slug&per_page=100');
}
// WordPress returns titles as HTML ("Tom & Jerry"); turn that into plain text.
export function plainText(html) {
return new DOMParser().parseFromString(html, 'text/html').body.textContent;
}
4. Set up the front end
4.1 Get the code
Click Download on the code window above and unzip it anywhere on your computer. You get one folder
with index.html, book.html, js/ and css/. There is nothing to install.
4.2 Point it at your site
Open js/config.js in a text editor and set your WordPress address, with no slash at the end:
export const WP_URL = 'https://your-site.com';
4.3 Run it on your computer
The pages must be opened through a web server, not by double-clicking index.html (browsers only run
JavaScript modules from a web address). Any small server works. From inside the folder, run one of:
npx serve . # needs Node.js; prints an address such as http://localhost:3000
python -m http.server 8000 # needs Python; then open http://localhost:8000
Open the address it prints. You should see your books, with their covers, and each one opens its own page.
4.4 Put it online
The front end is just files, so any static host can serve it, for free in most cases: Netlify (drag the folder onto its dashboard), Cloudflare Pages, GitHub Pages, or the public folder of any web host. Upload the whole folder as it is.
- Use HTTPS on both sides. A front end on
https://cannot load content from a WordPress site on plainhttp://; the browser blocks it as mixed content. - No CORS setup is needed. WordPress lets other sites read its public REST API.
- Updates are instant. The browser fetches from WordPress on every visit, so a book you publish shows up on the next page load.
How it fits together
- The HTML is a shell.
index.htmlhas an empty list and a<template>for one book card;book.htmlhas an empty article. - One file talks to WordPress.
js/wordpress.jsholds the fetch helper and one function per query. Every book request addsacf_format=standard, so the pages get ready-to-use images. - Each page script fills its page.
js/books.jscopies the card template once per book;js/book.jsreads?slug=from the address and fills in the article, using the cover's own alt text. - Titles and content arrive as HTML. Titles are converted to plain text before they go into
textContent; the post body is your own site's HTML and is inserted as it is.
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 |
Plain JavaScript or Next.js?
Here the visitor's browser builds each page, so search engines see "Loading…" first and the content only after the script runs. That suits small sites, a widget inside another site, and learning the API. For a full website where search ranking and first load matter, Next.js + ACF builds the same pages on the server.