How to Build a Headless Blog with Next.js & WordPress
I’m certifiably obsessed with Next.js and as such, have wanted to write some sort of tutorial on it for a while now. I stumbled upon some cool stuff while getting my blog set up and figured this would be a good place to start.
Quick note: I’m not providing a demo repo for this since it’s pretty straightforward. If you’d like to see to see this implemented somewhere for reference, the code for my site is available here.
Today, I want to cover:
- Installing WPGraphQL
- Initializing a new Next project
- Fetching data
- Setting up a blog landing page
- Setting up a blog page template
- Some cool tips to wrap up
This is a pretty beginner friendly tutorial but it’s not entry-level. With that in mind, I assume you have:
- Basic familiarity with React and Next.js
- Component architecture, folder-based routing, etc
- A working WordPress install
- Basic familiarity with WordPress
- How to make and edit content, how to install plugins
Let’s get started!
WP GraphQL
In my humble opinion, while a lot of what WordPress has to offer feels dated in the modern day, what it brings to the table in terms of content management is still awesome. That’s made even better by WPGraphQL which (as the name suggests), adds a GraphQL API to your WP install.
Why GraphQL?
From the WPGraphQL website,
“With GraphQL, the client makes declarative queries, asking for the exact data needed, and exactly what was asked for is given in response, nothing more. This allows the client to have control over their application, and allows the GraphQL server to perform more efficiently by only fetching the resources requested.”
In our case, adding a GraphQL layer to WordPress means we can request just what we need in a given context (say, title and description for the first 50 posts on the landing page; then title, featured image, and content for a single post when that post is clicked). WPGraphQL gives us a nice schema out of the box so once it’s installed, our backend work is done. Let’s do that.
Installing WPGraphQL
In your WP dashboard (as an admin), click on plugins in the sidebar, then “Add Plugin”. You’ll see this:
On the Add Plugin page, search for WPGraphQL.
This is the one you’re looking for (your’s will say “Install Now”). Install it, activate it, and we’re off to the races.
Next.js
Now that (assuming you have some blog posts written) WP is set up, let’s build our front end.
Initialize your project
In your terminal, run npx create-next-app
- name it whatever you’d like
- use tailwind
- use App Router
- use TypeScript
- don’t worry if you’re unfamiliar with TS
- configure the other options to your preference. Mine is:
- no src directory
- use ESLint
- don’t customize the default import alias
Add a file in the root of your project called .env.local
. In it, just make sure you have this line:
WORDPRESS_API_URL=https://yourWPInstall.com/graphql
Then, add a folder in the root of the project called actions
and in it, a file named wordpress.ts
. This structure is optional, but I prefer to keep all of my server functions/actions in an appropriately named folder, broken down into files based on utility and/or service. For instance, I might add an aws.ts
file to hold functions for interacting with an AWS service, sendgrid.ts
for interacting with SendGrid, etc. Speaking of…
Data fetching
In your wordpress.ts
file, add the following:
const url = process.env.WORDPRESS_API_URL;
export async function fetchAPI(query: string, { variables }: { variables?: any } = {}) {
if (url) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
const json = await res.json()
if (json.errors) {
console.error(json.errors)
throw new Error('Failed to fetch API')
}
return json.data
} else return (`You're missing your endpoint in your env vars.`)}
Starting at the top:
const url = process.env.WORDPRESS_API_URL;
sets the constant url
to the url we set earlier in the .env.local
file. Technically optional but improves readability, in my opinion.
We then define and export an asynchronous function with the name fetchAPI
. This function takes the required argument query
of type ‘string’ (hence the query: string
syntax) as well as the optional argument variables
which we’re destructuring (why it’s wrapped in curly braces). Since we don’t know what variables we’ll use in a given query before we write it, I’ve just left the variables
argument as an ‘any’ type. We’re also initializing it as an empty object by tacking ‘= {}’ at the end.
Now we’re in the function and the first thing we do is check to make sure we have a url. If we do, we continue. If not, we return that error at the bottom.
Then we use our query (and optionally, variables) to make a fetch request to our url. Once that resolves, we parse it to JSON and return the data. If it returns an error, we throw an error.
Now we’ve got a flexible function for fetching WordPress data. Let’s use it.
Landing page
Since it would be pretty silly to require users to know the exact URL of whatever post they want to read, we need a page that lists all of our posts.
If you want your blog to be its own page(yoursite.com/blog):
- make a folder in the app folder called
blog
- in
blog
, make a file calledpage.tsx
If your blog is the site: - go to
app/page.tsx
and erase the contents
Here’s what your page.tsx
should look like.
import { fetchAPI } from '@/actions/wordpress'
import Link from 'next/link'
export default async function BlogLandingPage() {
const data = await fetchAPI(`
query HomeQuery {
posts(first: 10) {
edges {
node {
title
excerpt
slug
}
}
}
}
`)
return (
<main>
<div className="w-screen min-h-[98vh] flex flex-row justify-center items-start bg-gradient-to-br from-slate to-black" >
<div className="h-full backdrop-blur-sm flex flex-col">
<div className="pt-36 md:pt-64 pb-24 md:pb-48 px-8 backdrop-blur-lg text-center flex flex-col justify-center">
<h1 className="text-6xl sm:text-8xl mb-6 font-semibold empTextDiv w-full py-2"><span className="empText text-center">{`Blog`}</span></h1>
</div>
<div className="flex flex-wrap gap-8 md:gap-12 px-4">
{data.posts.edges.map(({ node }:{node:any}) => (
<Link href={`/blog/${node.slug}`} key={node.slug}>
<article className="p-8 max-w-xl hover:scale-[102%] transition-all duration-300 bg-gradient-to-br from-slate to-slate-dark border-black rounded-lg">
<h2 className="text-2xl font-medium text-white mb-4">{node.title}</h2>
<div className="text-offWhite font-light md:font-normal" dangerouslySetInnerHTML={{ __html: node.excerpt }} />
</article>
</Link>
))}
</div>
</div>
</div>
</main>
)}
export const revalidate = 1
Let’s break that down.
The first big thing to note is that everything following className
is style related (that’s Tailwind for you). You can change all of that to your heart’s content, or simply remove it if you’d like to focus on function before form. That’s the case for every page/component we build going forward as well.
The first thing we do is import our fetchAPI
function from earlier. We also import the Link
component from Next.js. That does some cool stuff and you can read more about it here. Long story short, it’s neat.
We then define our main page component. Since we have to await the results of our fetch function, our page has to be async.
We call our fetch function and pass our query. With this query, we’re fetching the title, excerpt, and slug for the first 10 posts. You can adjust this as needed.
The page essentially consists of a gradient background, an h1, and then a link for each post. We generate those links by mapping over posts.edges
so that each node (individual post) returns a link to /blog/node.slug
that wraps an article element with an h2 set to the post’s title, and a div with the except injected into it with dangerouslySetInnerHTML
. If you’d like to know more about the article
HTML element, here’s some good documentation.
Finally, we export a constant called revalidate
. This determines how long Next will wait to re-fetch the data vs serving it from the cache – measured in seconds. I have it set to 1
for development purposes. Practically, I would have the landing page set to 60*60
to revalidate once per hour. If you’d like to learn more about revalidation in Next (time-based or otherwise), here’s a link to their docs!
Now if we navigate to that page, we should see the first 10 posts on our WordPress install. Only problem is if we click on one, nothing happens. Let’s fix that.
Post page
If you’re using the /blog
format, do this in that folder; otherwise, in your app
directory:
- make a folder named
[slug]
- the brackets are super important here as they indicate a dynamic route
- in
[slug]
, make anotherpage.tsx
Here’s what that file should look like.
import { fetchAPI } from '@/actions/wordpress'
export default async function BlogPost({ params }:{params:any}) {
const data = await fetchAPI(`
query PostBySlug($slug: ID!) {
post(id: $slug, idType: SLUG) {
title
content
date
author {
node {
name
}
}
featuredImage {
node {
sourceUrl
altText
}
}
}
}
`, { variables: { slug: params.slug } })
const post = data.post return (
<main className="w-screen min-h-[98vh] pt-16 pb-16 flex flex-row justify-center items-start bg-gradient-to-br from-slate to-black">
<article className="pt-36 md:pt-48 px-[10%] md:px-[30%] w-full">
<h1 className="text-4xl sm:text-6xl mb-4 font-semibold text-white sm:leading-[5rem]">{post.title}</h1>
<div className="text-offWhite mb-16">
By YOURNAME
</div>
<div
className="prose lg:prose-xl prose-neutral prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
</main>
)}
export const revalidate = 1
Here’s what all that means.
We start by importing our fetching function like before but this time, its use is a bit different – we’re getting a single post’s data using its slug.
We then establish our template in the return function. In this case, I chose to set the title as the h1, then have a byline, and then go right into the content. You could also add the publication date, featured image, or any other data fetchable from WP, just by adding it to your query and accessing it the same way we are with the title and content.
We’re injecting the content like we did before with dangerouslySetInnerHTML
. This time though, we have some fun Tailwind classes, prose lg:prose-xl prose-neutral prose-invert
, specifically.
These belong to the Tailwind typography plugin and will format the content we inject automatically for us. to install the plugin, run: npm install @tailwindcss/typography
in your terminal. Then make sure to add the following to your tailwind.config.ts
file:
plugins: [
require('@tailwindcss/typography'),
// ... your other plugins, if any
],
In case you’d like an example, that section of my config looks like this.
plugins: [require("tailwindcss-animate"), require('@tailwindcss/typography')],
Then (back on page.tsx
), we’re exporting another revalidate const. This is also set to 1
for development purposes. In production, I’d have this set to 60*60*24*30
to revalidate it once a month.
That’s it! We now have a working headless blog. However, we have two more steps to go. Handling loading and error states. Let’s knock those out.
loading.tsx
This goes in [slug]
and is what’s displayed while the post is being fetched. Mine is pretty simple.
export default function Loading() {
return (
<div className="w-screen min-h-[98vh] flex flex-row justify-center items-start bg-gradient-to-br from-slate-dark to-black">
<div className="max-w-2xl mx-auto py-8 px-4 text-white">
Loading post...
</div>
</div>
)
}
It’s just a stylized page with some loading text. Having one of these is nice because it never leaves the user wondering what’s happening.
error.tsx
If there’s a problem loading the post, we want to handle that. This also goes in [slug]
.
'use client'
import React from 'react'
export default function Error({ error, reset }:{error:any; reset:any}) {
return (
<div className="w-screen min-h-[98vh] flex flex-row justify-center items-start bg-gradient-to-br from-slate-dark to-black">
<div className="max-w-2xl mx-auto py-8 px-4 text-white">
<h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
<button
onClick={() => reset()}
className="bg-slate-500 hover:bg-slate-600 text-white font-bold py-2 px-4 rounded"
>
Try again
</button>
</div>
</div>
)
}
The first thing that’s different here is that we start with a "use client"
directive at the top. Up until now, we’ve been using server components, which means everything gets rendered on the server and sent to the client after the fact. This component is a client component, which means it’s rendered on the client (and the server but that’s a topic for another day). This is needed anytime we want interactivity in a component. In this case, that interactivity is the “Try again” button.
That try again button calls the reset
function (you can see that’s in the arguments for this component) provided by Next.js. That function’s one job is to attempt to reload the content that errored out. It’s nice to put that there to give the user an action instead of leaving them at a dead end.
The rest of that component should look pretty familiar by now. Just HTML and some styling.
Congrats! Some Final tips
That’s everything! You should now have a working headless blog complete with error and loading state handling, just waiting to get deployed. Here are a few last minute tips:
- Tailwind Typography allows for a bunch of customization options. You can learn more about them here.
- If you write your post in a markdown editor (like Obsidian), you can copy and paste directly into the WordPress editor and it will format your text appropriately. Great for writing offline and only publishing when ready.
Hope that helped!
-Dean