Skip to content

Chris SilichCreative Technologist

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

Posted Wednesday, June 14, 2023

This series of blog posts is about how I set up this very site you’re on. Well, I’ll show you most of the process. I’m not giving you my css secret sauce.

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.

To keep things simple, I’m going to be spinning up the WordPress site entirely separately from the NextJS site, and keeping them decoupled. We’ll only be doing the most basic security precautions to prevent other people scraping our API. If you have concerns about “hiding” your API, hire a Back End / DevOps engineer who can set this up in a more concealed way.

This tutorial is for people who are familiar with WordPress, ACF, AJAX, and React, but might be a little light on NextJS, the WordPress JSON API, and TypeScript.

Let’s dive in.

1. Set up WordPress and Advanced Custom Fields

Honestly, this is the easiest part. We need a boring, vanilla installation of WordPress on a server somewhere. Doesn’t matter much where. You can handle that, or you wouldn’t be here.

Dumb that theme down. I took one of the other themes that came with WordPress and stripped it down to just the style.css, index.php, and functions.php file. In index.php I took everything out and put “What are you looking at?” to intimidate hackers. In style.css I kept the comment at the top, wiped everything else, and changed the name of the theme to “Just JSON” (and the author and all that). The effect here is that the theme is blank and gives no hints to anyone what this site is for.

In functions.php, add this function:

function chrissilich_init_cors( $value ) {
	$allowed_origins = [

	if ( array_key_exists('HTTP_ORIGIN', $_SERVER) && in_array( $_SERVER['HTTP_ORIGIN'], $allowed_origins)) {
		header( 'Access-Control-Allow-Origin: ' . $_SERVER['HTTP_ORIGIN'] );
		header( 'Access-Control-Allow-Methods: GET' );
		header( 'Access-Control-Allow-Credentials: true' );
	return $value;

add_action( 'rest_api_init', function() {
	remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
	add_filter( 'rest_pre_serve_request', 'chrissilich_init_cors');
}, 15 );

The effect of the above code is to identify which servers are allowed to connect to our API. It checks the incoming request’s HTTP_ORIGIN, which is the domain of the requesting website, and tries to match it against our whitelist in $allowed_origins. If it is found, then we set the Access-Control headers to what we need them to be for a JSON response. You can read more about this subject here.

You can see that mine only allows, but during development I also had http://localhost:3000 in the list so my local Next app could connect.

I also set the permalink structure to “Post name”. This enables the UI in the post editor that let’s a content author set a URL slug. This is important because we’ll have NextJS query WordPress for posts by slug sometimes.

Verify that the WordPress API is responding correctly by going to (your equivalent of) this URL If you want to dive into the docs regarding the WP JSON API, they’re here, and here’s a great blog post that goes deeper on them.

That’s it for the WordPress part for now. If you want, now would be a good time to add the security plugin of your choice. You don’t need a caching plugin, because Next will handle image optimization and caching for us.

2. Start your NextJS project

Start by doing the usual NextJS installation documented here.

npx create-next-app@latest
cd [folder-name]
npm i
npm run dev

I picked TypeScript, ESLint, src directory, App Router, Import Alias (@/*). I didn’t do Tailwind because I don’t like it. You can change these settings, but my code samples might not work properly if you do, so proceed with caution. Definitely use the App Router. It’s the new thing in NextJS, and the old “pages router” will be in decline from now on, so it’s time to move.

This is about when I usually take some time to wipe out some of the boilerplate code and make sure I understand the routing logic of the framework I’m building. For my app, I’ll be building things in this URL structure:

/			homepage
/blog 			blog archive page
/blog/[slug] 		single blog article
/projects/		projects archive page
/projects/[slug]	single project page
/[slug] 		other wordpress pages

Note that in the above list, [slug] means we’ll catch anything put there and try to find it’s corresponding post/project/page in WordPress. If we do things right, we can have the URLs in NextJS match exactly their content in WordPress, though that’s not really necessary.

3. Verify we can get data from WordPress

There should already be a Hello World! blog post in your WordPress site. It comes with one for free. Check (your equivalent of) this URL in your browser to see if you can see the blog posts archive in JSON form. There are pagination and various other options too, but we can skip those for now. Add ?slug=hello-world to query for just the Hello World! post. If it works, we can move on. If not, check the docs and figure out how the URLs are wrong.

If it looks like gibberish, it might be good to add a browser plugin that formats JSON nicely for readability.

WordPress outputting our content? Check.

4. Set up Routing in NextJS

In NextJS, let’s create a new folder at /src/app/blog/, to handle our blog routes, and put a page.tsx file in it. Add this code to check if your NextJS routing is working, and navigate to http://localhost:3000/blog to test it.

export default function BlogArchive() {
	return (
			<h1>Blog Archive</h1>

Do the same with /src/app/blog/[slug]/ and give it a similar component in its own page.tsx, but with “BlogSingle” for the function name. What? Are those square brackets in the folder name? Yep. Those tell NextJS to route anything for that section of the URL into this folder’s page.tsx, and send in the URL segment string under the name slug. That’s how we get our /blog/[slug] URLs to work, but you’d know that if you read about the App Router up there in section 2 above when I linked to it and told you to. It’s called Dynamic Routing. There you go, do your homework this time. This will be on the quiz.

Next modify your Single Page component in /src/app/blog/[slug]/page.tsx to receive that [slug] part of the URL as a variable:

export default function BlogSingle({ params }: { params: { slug: string } }) {
	return (
			<h1>Blog Single</h1>
			<p>Slug: {params.slug}</p>

If you’re not used to TypeScript and destructuring, that parameter will look crazy, but it basically says we’re expecting an object, in which there will be a params property set to an object, and in that we’re expecting it to have one slug property, which is a string. And we’re destructuring it into just the params object.

If you did this correctly, now you should be able to go to http://localhost:3000/blog/is-this-thing-on and the page should show you Slug: is-this-thing-on on the page.

NextJS routing? check.

5. Make NextJS ask WordPress for Blog Posts

Next we’ll be writing our AJAX requests from NextJS to WordPress, but there are some file/folder organization conventions we should follow.

Make a folder at /src/services/ with a file inside called endpoints.ts. This file will be our single location for all the WordPress API URLs we will be using. We want to keep them all in one place so that other developers can get a sense of the whole app’s connections to the API in one place, and so we don’t repeat URLs in several places that may need them. In mine, I have this:

export const API_BASE = ''
export const BLOG_ENDPOINT = API_BASE + 'posts/'

Note: If you secure your API later and need to send API keys, a password, or anything else secret with your API URLs, put them in an environment variable file that is not committed to your source control, and bring it into NextJS like this.

Next let’s make a service file for fetching the archive of blog posts from WordPress. Create blog.ts and add this code:

import { BLOG_ENDPOINT } from './endpoints'

export const getPostArchive = async () => {
	const response = await fetch(BLOG_ENDPOINT, {
		next: { revalidate: 60 },
	const data = await response.json()
	return data

As you can see, this is about as simple as it gets. A basic fetch request to the WordPress API URL we figured out earlier. But is it? Nope, it’s NextJS’s beefed up version of the fetch API. It adds caching, deduplication, and revalidating to fetch. In your /src/spp/blog/page.tsx, we need to do a few things to wire this up.

  1. Import the getPostsArchive function from the new blog service
  2. Make the main BlogArchive component function async
  3. Call the getPostsArchive function and await the response
  4. And loop through the results, rendering the post titles in a list.

That should look like this in the end:

import { getPostArchive } from '@/services/blog'

export default async function BlogArchive() {
	const posts = await getPostArchive()
	return (
			<h1>Blog Archive</h1>
				{ => (
					<li key={}>{post.title.rendered}</li>

Now you should be able to go to /blog in your NextJS app and see your WordPress blog posts. Well, their titles at least, but still, cool right? Your Headless WordPress NextJS app works! Call it a pre-pre-alpha!

You might have noticed, no TypeScript yet, and the post.title.rendered part came out of nowhere. I got that by looking at the JSON returned by WordPress and inferring the structure, but that’s no good. Let’s add proper type safety.

Wrap Up

Phew! So what did we accomplish? We set up WordPress with the JSON API, set up NextJS with some custom routes, and wrote some services to have NextJS fetch blog post content from WordPress. Pat yourself on the back, that’s the core functionality were looking for. The rest is all just improvement.

Next Post

In the next blog post, we’ll make all this moving data type-safe with TypeScript, add some styling, and bring those styles over to WordPress so the editor window is an accurate preview.

← Back to Blog Archive