Skip to content

Chris SilichCreative Technologist

Headless WordPress REST / JSON API + React / NextJS (with App Router) Part 3

Posted Thursday, June 22, 2023

This series of blog posts is about how I set up this very site you’re on. Well, most of the way.

The back end is WordPress as a Headless CMS using the built in REST/JSON API, and the front end is a React / NextJS app (using the new App Router). I’ll also be using SCSS for styling, and Typescript for making the data transfer more safe and predictable, and some other goodies.

In the first post in the series, we set up WordPress, NextJS, made some blog posts, and used NextJS’s upgraded fetch API to fetch them and display them.

In the second post, we implemented TypeScript and figured out a good CSS strategy for a site built in Next but also needing to export the CSS for the WP editor.

This time we’ll be adding routing and services for our custom post type (Projects) and Pages, and handling ACF custom fields for both Posts and Projects.

Let’s keep going!

10. Projects custom post type

Some of this should be obvious if you understood the process for blog posts, but there are some curveballs for custom post types. Let’s start by adding the plugin Advanced Custom Fields, which lets us make new post types and fields for them. Add a post type called project, and then add all the fields you might need need to display a project on this website. In the example code in this blog post I’ll implement a client (text string), medium (text string), and main_image (image), so at least make those, but you can go ahead and make more if you like. Be sure to go into the Advanced Configuration section and verify that “Show In REST API” is checked. The rest can be left default.

After creating the project type, go and create 2-3 projects in the CMS. Placeholder content is fine. Then verify that we can fetch the content via WP API by visiting your equivalent of this URL in your browser:

11. Projects endpoint and service

In your /src/services/endpoints.ts file, add a few more entries to represent the URLs we need for fetching projects. The file should look something like this now:

export const API_BASE = ''

export const BLOG_ENDPOINT = API_BASE + 'posts/'
export const BLOG_ENDPOINT_SINGLE = BLOG_ENDPOINT + '?slug='

export const PROJECT_ENDPOINT = API_BASE + 'projects/'

And the actual service file should be almost exactly the same as blog.ts, so let’s just duplicate and modify that one. Change the function names, the endpoint constants, and the name of the interface (which we’ll make next).

import { Project } from '@/interfaces/project'

export const getProjectArchive = async () => {
	const response = await fetch(PROJECT_ENDPOINT, {
		next: { revalidate: 60 },
	const data: Project[] = await response.json()
	return data

export const getProjectSingle = async (slug: string) => {
	const response = await fetch(PROJECT_ENDPOINT_SINGLE + slug, {
		next: { revalidate: 60 },
	const data: Project[] = await response.json()
	if (data.length > 0) {
		return data[0]
	} else {
		return null

Your code editor should be underlining the import statement because that interface doesn’t exist yet. Let’s make that next. Grab your JSON from the test we did earlier in the browser and paste it into QuickType (or similar service). Generate the interfaces, check them for quality and weirdness, and paste them into a new file at /src/interfaces/project.ts. Notice how there’s now a property called acf with your custom fields in there. That’s how easy the custom fields integration is from the back end. If you add more fields, you will have to adjust your interface for that acf property.

12. Projects routes and displaying Images

By this point, hopefully you’ve got the routing figured out and you can already tell that we need /src/app/projects/page.tsx for the archive, and /src/app/projects/[slug]/page.tsx for the single. Start with a copy of the blog routes and modify, they start almost the same. The major changes will obviously come when we try to display custom fields:

<h2>{project.acf.client} | {project.acf.medium}</h2>

When we use images, things get more complicated. WordPress doesn’t provide full image data with WP API responses, just the image ID. We have to write another service (and interface) to get the image. There are two options here.

  1. In our page component, we can fetch the project data, then dig into it to find the image ID, and request that image data from a new service.
  2. In our project service, we could do a second request for the image data and insert the response into the project object (and modify its interface to deal with the additional data).

I’m going to go with option 2 because with option 1, on pages with multiple projects, like the archive page, we would then be handling multiple media items separately, and then trying to match them back up. Better to just do all the queries and keep things packaged together.

First we need a new endpoint in our endpoints.ts file like this:

export const MEDIA_ENDPOINT = API_BASE + 'media/'

Then we also need a new media service in /src/services/media.ts responsible for asking WordPress for the details of anything in the media library via that endpoint. Once again, it’s very simple, and almost the same as the other services, only this time we just have the one function.

import { MEDIA_ENDPOINT } from './endpoints'
import { Media } from '@/interfaces/media'

export const getMedia = async (id: Number) => {
	const response = await fetch(MEDIA_ENDPOINT + id, {
		next: { revalidate: 60 },
	const data: Media = await response.json()
	return data

And we need to take a copy of the JSON produced by that endpoint, convert it to TypeScript interfaces, and save it as /src/interfaces/media.ts. We’ll also add a new entry to the project interface, under the main Project interface, for this new media info. Note that we allow it to be null because when we first fetch the project, it won’t have this, and we’ll inject it afterwards.

main_image_media: Media | null

And finally we need to modify the projects service to also call the media service when it needs media data. I’ll show the code then explain it:

export const getProjectArchiveWithMedia = async () => {
	const response = await fetch(PROJECT_ENDPOINT, {
		next: { revalidate: 60 },
	const projects: Project[] = await response.json()

	await Promise.all( (project) => {
			if (project.acf.main_image) {
				project.main_image_media = await getMedia(project.acf.main_image)
	return projects

So skipping over Promise.all for a moment, you should be able to see that we’re looping through the projects we just fetched, checking if they have a acf.main_image property, which is the image’s ID in WordPress, and if it does, asking the media service’s getMedia function to get it for us. The Promise.all part is necessary because getMedia is an async operation, and we might need to wait for a lot of these to finish. Don’t worry, NextJS is going to cache the results, and even when the cache is old, it will give the user the expired copy and then update it during hydration.

At this point we should have project data with main_image media being injected into it, headed for the page.tsx file. In NextJS, it’s recommended that you use the Next Image component because it handles image resizing and a handful of other things. So here’s our new code for showing the image in the Project archive. In order to use the Image component, you have to tell NextJS what URLs it’s allowed to pull images from to prevent abuse by naughty people on the internet. You do this by altering your next.config.js file like so:

const nextConfig = {
	images: {
		domains: [''],

And then you can add the Image to your page.tsx, but you’ll need to wrap it in a null check, since it’s possible in the interface for it to be null.

{project.main_image_media && (
	<Image src={project.main_image_media.source_url} 

So now you can follow this pattern for images and other custom fields anywhere on your site.

Wrap Up

So now we have our projects custom post type, and all those custom fields working nicely,

Next Post…..?

Hopefully there is a next post coming. I plan on adding a generic route to catch all generic WordPress pages, and then figure out how to deploy WordPress Gutenberg blocks to a NextJS front end. But first I better turn this thing back into my portfolio site.

← Back to Blog Archive