Headless WordPress and Next.js

This article breaks down my headless WordPress and Next.js setup and highlights some of of the things I’ve learnt while re-building my blog and portfolio website with this stack.

Thumbnail for the blog post

Storyblok, Ghost, Markdown files… I’ve tried them all. And even though some CMS solutions out there bring incredible and innovative features, all roads led me back to WordPress as a CMS for my blog and portfolio.

This article breaks down my headless WordPress and Next.js setup and highlights some of of the things I’ve learnt while re-buildinng my blog and portfolio website with this stack.

Why WordPress??

WordPress isn’t exactly the most modern CMS out there and unlike Storyblok, for instance, it has never been designated to work as a headless CMS.

The Gutenberg editor

This is probably a controversial take but I honestly like the Gutenberg editor a lot. For many use cases it still offers the best WYSIWYG(-ish) experience on the market and the customization options through plugins and themes are endless.

Especially when thinking about a portfolio that showcases projects, custom Gutenberg blocks can provide an amazing and interactive editing experience. This is, for instance, how I can edit a project on my portfolio page within the Gutenberg editor:

The ecosystem

Sometimes, you just want to get things done without having to re-invent the wheel. The fact that there’s a WordPress plugin out there for almost everything really helps with that

Implementing an email newsletter, an automatic table of contents, automatic Facebook posting… Adding such features can be done in a single click in WordPress.

Force of habit

WordPress is really wide-spread. Almost anyone who works with websites, has worked with it at least once in their life. This makes it the perfect choice for a CMS when building projects for clients who may not be as experienced with other systems and like working in their familiar environment.

And even I have to confess that the reason I used WordPress as a headless CMS for my website has to do with the fact I’ve been working as a WordPress developer a lot the last couple of years and have grown really fond of it.

My headless WordPress setup

Here’s a little drawing of how my setup looks:

Screenshot of the headless WordPress and Next.js setup.

VPS and Docker

In my previous article “Scaling Node.js Web Apps with Docker” I already talked about deploying and scaling a Next.js app on a VPS using Docker and Docker Compose. I still use that setup with slight modifications as it provides a simple and really cost-efficient way of deploying both the Next.js front end and the WordPress instance.


The REST API built into WordPress already provides everything I need to query posts and other metadata such as taxonomy (category/tag data) and author data.

However, if GraphQL is your thing, I definitely recommend the WPGraphQL plugin.

The WordPress REST API is really easy to use which is why I didn’t opt for a client-side SDK within my Next.js app. All I did was build a very light wrapper around the HTTP calls to handle things like authentication and building the querystring:

 * Light wrapper around the WordPress REST API to fetch resources.
 * @param endpoint The endpoint to fetch from 'wp-json/wp/v2/{endpoint}'.
 * @param query The query parameters to append to the querystring.
 * @returns The JSON response from the WordPress REST API.
export const getWpRessource = async (endpoint: string, query: any) => {
  const WP_REST_API_URL = env.WP_REST_API_URL + '/wp/v2';

  // Create the querystring from the query object.
  const requestQueryString = querystring.stringify(query, { arrayFormat: 'bracket' });

  // Put together the full URL for the call.
  const requestUrl = `${WP_REST_API_URL}/${endpoint}/?${requestQueryString}`;

  // Make the HTTP request to the WordPress REST API using the fetch API.
  const response = await fetch(requestUrl, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',

  // Return the JSON response.
  return await response.json();


Why do I need webhooks in the first place? I’ll go into detail about that a bit later in this post. For now, it’s just important to know that WordPress does not provide a way to create webhooks natively.

However, there are many plugins out there that take on that task. I created a very lightweight webhook plugin myself which I use in my setup. It’s called WP Hook Expose. WP Webhooks is a great option too, if you would like more control over your webhooks and the data they send.

Part 1: Building the blog

Building the blog on my website using Next.js for the front end and WordPress for the content was probably the most straightforward thing in the process.


Using the Next.js app router and the built-in file-based routing, the setup looks like this:

├── [slug]
│   └── page.tsx (A single blog post)
├── categories
│   └── [slug]
│       └── page.tsx (A single category)
└── page.tsx (Overview of all blog posts)

Exporting paths

To properly pre-render each blog page at build time, it is important to fetch the paths for each blog post and provide it to Next.js.

Statically fetching the paths from WordPress in [slug]/page.tsx looks something like this:

 * Export possible paths for this page.
export async function generateStaticParams() {
  // Get all post slugs from WordPress.
  const postSlugs = await getWpRessource('posts', {
    _fields: 'slug',

  return postSlugs.map((post: { slug: string }) => ({ slug: post.slug }));

The paths are static and only fetched at build-time. They can, however, still be updated at a later point during runtime using On-Demand-Revalidation and webhooks. But more on that later!

Rendering the blog post

The rendered HTML for the blog posts can simply be retrieved through WP Rest and injected into the DOM using dangerouslySetInnerHTML:

 * A single blog post page.
const BlogPostPage = async ({ params }: { params: { slug: string } }) => {
  // Get the full post from WordPress.
  const posts: WP_Post[] = await getWpRessource('posts', {
    slug: params.slug,
    _embed: true,
  const post = posts[0];

  // If the post doesn't exist, return a 404.
  if (!post) {

  return (
      <div className={styles.wordPressContent} dangerouslySetInnerHTML={{ __html: postContent }} />

And that’s it! Now all that’s left to do is building some styling around it and an overview page to list the blog posts.

Part 2: Building the portfolio

For the portfolio part of my website I wanted a bit more interactivity and client-side rendering. I implemented a lot of it in native React components.

Nevertheless, I wanted the content (in this case the projects to showcase) to be dynamic and editable at any time in WordPress.

That is why I decided to create a Custom Post Type for portfolio projects:

You can create Custom Post Types either through code or the plugin “CPT UI“.

The idea is that Next.js can query all posts of the post type “Portfolio Project” and render them in a designated area on the portfolio page.

The rendering of each project itself, however, is done by WordPress which gives you ultimate control over the look in the Gutenberg editor (perfect if you want to build your own blocks like me).

This way, we can even encapsulate different styles for different projects in our custom blocks (e.g. “Large Project”, “Projects Grid”, “Project Banner”, …).

But you can, of course, also just stick to the default blocks built into Gutenberg or use blocks of third-party plugins.

Updating posts and pages

As you can see above, so far the pages and paths are static. That means, Next.js will fetch all content and data from WordPress at build-time, render every page, and then never re-fetch data from WordPress.

This results in a great performance as we don’t have the overhead of additional HTTP requests or the overhead of all the dynamic PHP code and database queries WordPress needs to render a page.

But one would still want to update their content every once in a while… and re-building as well as re-deploying the entire site for that seems kind of exhausting.

A solution for that is On-Demand-Revalidation. This feature of Next.js allows you to re-build static pages on your site at runtime and only if necessary which results in a great balance between server-side-rendering and serving static assets.

Implementing On-Demand-Revalidation

One way to make On-Demand-Revalidation work in your Next.js app when using WordPress as a CMS is through API routes.

You simply create an endpoint in your Next.js app that can revalidate the cache and send a request to that endpoint everytime the content in WordPress changes.

In Next.js, you can create API endpoints using Route Handlers.

Here’s an example of how that could look like (/api/revalidate/route.ts):

export async function POST(request: NextRequest) {
  // Get the request body (JSON).
  const body = await request.json().catch(() => ({}));

  // Check if the request body has the required fields.
  if (body['wp_webhook_secret'] === undefined || body['args'] === undefined) {
    return new Response(JSON.stringify({ success: false, message: 'Missing token or args field.' }), { status: 400 });

  // Check if the webhook secret is correct.
  if (body['wp_webhook_secret'] !== process.env.ADMIN_API_KEY) {
    return new Response(JSON.stringify({ success: false, message: 'Invalid webhook secret.' }), { status: 401 });

  // Check if the args field has the required fields.
  if (body['args'][1]['post_type'] === undefined) {
    return new Response(JSON.stringify({ success: false, message: 'Missing post_type field.' }), { status: 400 });

  // If post_type is post, revalidate the blog cache.
  if (body['args'][1]['post_type'] === 'post') {
    revalidatePath(`/blog`, 'page');
    revalidatePath('/blog/[slug]', 'page');
    revalidatePath('/blog/categories/[slug]', 'page');
    return new Response(JSON.stringify({ success: true, message: 'Post revalidation order created successfully.' }), { status: 200 });

Implementing webhooks

Now, the only thing left to do is sending an HTTP request to this API endpoint every time content in WordPress changes.

There’s a bunch of different ways this can be done. For instance, you could build a custom WordPress plugin that hooks into the save_post action and send the HTTP request to Next.js from there.

Another way would be to use a plugin that enables you to create webhooks within WordPress.

I decided to build my own, lightweight plugin to do this. I didn’t really want the overhead of a full-fledged webhook plugin but I also didn’t want to hard-code everything in my WordPress instance.

My plugin is called WP Hook Expose and it is open-source and free for anyone to use. However, if you would like a plugin with a bit more configuration options, I can recommend WP Webhooks.


There are tons of amazing content management systems out there. What it comes down to is your personal preference and the kind of site you want to build.

For highly interactive sites it can be detrimental to render a big portion of your HTML outside of React. However, for content-driven sites that need extensive editing and content management WordPress is definitely worth a shot!

It ships with a huge ecosystem of plugins that can save a lot of time and many content editors and developers are familiar with WordPress and the Gutenberg editor.