Headless WordPress is Overrated: A Case for The Nearly-Headless Web App

Posted on:

Over the last few years, I've built a number of fully headless WordPress websites with the REST API and React. I loved how fast the end result is, and if done correctly, how powerful and extend-able you could eventually make page creation. Plus it just feels nice. Loading transitions, and the general behavior just makes your website feel fresh, and modern. It provides a polish that even the fastest non-headless sites can't quite achieve.

I avoid creating headless WordPress websites. It creates a lot of extra overhead, which creates more bugs, and ultimately ends up making the site much harder to maintain. I typically stick to the basics - fast hosting, and aggressive caching.

Headless WordPress kind-of sucks. You give up a lot of help from WordPress in-search of an app experience. I have found myself frequently dreaming "what if I could have it both ways?"

The Problem With Headless

One problem with fully-headless WordPress is routing. Behind the scenes, WordPress has a lot of logic built-in to handle routing, and with a headless approach you have to build something to handle that on the front end. Ultimately, you're re-inventing the wheel, and it takes a lot of extra time to build.

Another problem with headless WordPress quickly becomes apparent the moment you try to use most WordPress plugins. The ugly truth is that you usually have to re-invent a lot of things just to get the plugin working properly. For example, try to create and use a Gravity Forms form, and then use it in a React app. You can't really do it without re-building the form's render, validation, and submission logic on the front-end. That's a lot of extra overhead to maintain. This example is something as simple as adding a form to a website. Imagine how complex things get when you look at things like integrating e-commerce tools, like Easy Digital Downloads, or WooCommerce.

Re-Thinking "Headless WordPress"

I knew when I committed to upgrading my personal theme, I wanted it to be a fast, and have an app feel, but I didn't want to completely sacrifice all of the natural capabilities that WordPress plugins offer. This site, for example, uses LifterLMS for its courses, and it would have taken a lot of extra time to rewrite all of those course templates from-scratch.

I also wanted to use this tech for our premium clients at DesignFrame. Because of that, we needed a way to maximize compatibility with WordPress's native features, and ensure that plugins remain compatible with whatever we build.

So, with that, here's the key parameters of this approach:

  1. The theme had to be compatible with other plugins without re-building a bunch of custom logic in the process.
  2. Other developers should be able to pick up and work with the theme with a minimal learning curve.
  3. The site needs to just work with WordPress's block editor without needing any changes to the theme.

These parameters immediately revealed a couple technical truths:

  1. The HTML markup needs to be rendered inside WordPress. This ensures that plugins can render their output in the same way they do with any other theme.
  2. The app needs to rely on WordPress for routing, and needs to handle any custom page from any plugin without fail.

Neither of these things fit the description of headless WordPress.

Enter Our Nearly Headless Web App

I like to call this a nearly headless app. Partially because it makes sense - the app still relies on the server to get started, but once the server provides the initial load, the app can usually take it from there. But let's be real, I just wanted an excuse to put John Cleese in my blog post.

Here's the gist:

  1. The system uses AlpineJS for rendering. It's light, fairly easy to understand, and it plays exceptionally nice with PHP server-side rendering.
  2. Most of the theme is is loaded around HTML template tags. These tags get populated by WordPress's REST responses for post content.
  3. The system makes judicious use of session storage. This drastically reduces the number of REST API calls, and keeps the site running fast.

How Nearly Headless WordPress Works

The site is loaded just like any regular ol' WordPress site. The key difference is "the loop" is replaced by a template tag, which uses Alpine's x:forEach loop to actually render the loop. It looks something like this:

  "name": "acf/snippet",
  "attributes": {
    "id": "block_60df0a314f162",
    "name": "acf/snippet",
    "data": {
      "language": "php",
      "_language": "field_60d7f32b1b540",
      "code": "<?php\r\n/**\r\n * Index Loop Template\r\n *\r\n * @author: Alex Standiford\r\n * @date  : 12/21/19\r\n * @var $template \\Theme\\Templates\\Index\r\n */\r\n\r\nif ( ! theme()->templates()->is_valid_template( $template ) ) {\r\n\treturn;\r\n}\r\n\r\n?>\r\n\r\n<div class=\"loop\">\r\n\t<template x-for=\"( post, index ) in posts\" :key=\"index\">\r\n\t\t<div>\r\n\t\t\t<template x-if=\"'post' === getParam(index, 'type')\">\r\n\t\t\t\t<?= theme()->templates()->get_template( 'post', 'post' ); ?>\r\n\t\t\t</template>\r\n\t\t\t<template\r\n\t\t\t\tx-if=\"'page' === getParam(index, 'type') || 'course' === getParam(index, 'type') || 'lesson' === getParam(index, 'type')\">\r\n\t\t\t\t<?= theme()->templates()->get_template( 'page', 'page' ); ?>\r\n\t\t\t</template>\r\n\t\t</div>\r\n\t</template>\r\n</div>\r\n",
      "_code": "field_60d7f36c1b541"
    "align": "",
    "mode": "edit"
  "innerBlocks": []

I'm using Underpin's template system in the example above, but you could just as easily do this with get_template_part() instead of get_template().

Once the page loads, AlpineJS fires up, and renders the content using the REST API. Since the initial endpoint is preloaded, it grabs the data from the cache, loops through the content, and renders the result. The REST response is also saved in session storage (more on that later).

Behind the scenes, the app scans the entire rendered page for internal site links, gathers them up, and sends them to a custom REST endpoint. This endpoint takes the URLs, retrieves the post object associated with each one, and returns them to the app. The app takes these objects and puts them in session storage for later use.

When a link is clicked, the app intercepts the event, and checks to see if the post for that link is stored in session storage. If it is, it re-renders the page using the data from session storage, and pushes the URL to the browser's history. If the page content isn't stored in session storage, it simply loads the link using the default behavior.

Key Benefits

Your Website Works Less

Because most of the content ultimately gets loaded from the session storage, the app has all of the information needed to render content without contacting the server. This takes a lot of strain off of the server by reducing the number of requests a visitor makes when exploring your website. Not only does this mean your site will run faster, it also means your site will be able to handle more concurrent visitors without slowing down.

Faster Experience On Slow Networks

Another benefit of the nearly headless WordPress app is how much better this app performs on a slow network. I spend a lot of time in the boonies, so I'm painfully aware of how much an optimized website can improve a person's experience. The initial load won't be any better than a normal site, but when it loads, the rest of the content is fetched in another request. Once that loads, the site will load instantly, even though the network is slow.

In fact, in testing, I was able to load the initial page, turn on my iPhone's airplane mode, and still navigate most of the site as-if I had a lightning fast connection.

Problem Pages Can Bypass the App

The app only instant-loads if the content is in the session storage. This means that you can "disable" the app on pages that, for whatever reason, need to run through a WordPress request when visited. This theme includes settings page that makes it possible to add a list of pages to explicitly force to load in this manner.

This makes it possible to fallback to a more-traditional theme load on pages that somehow conflict with the app. That gives us a way to quickly fix pages that are behaving unexpectedly without needing to make any immediate changes to the theme.

This allows me to quickly put a quick-fix to problems that crop up, and then implement the necessary upgrades to the theme to fix the conflicts and re-activate the app on that page.

This also allows us to completely disable headless WordPress when it's convenient from a technical standpoint. Some pages would require a lot of extra work to re-build using REST. For example, a cart page on a website that uses an e-commerce solution would require a significant rewrite of the template because these plugins expect a traditional request to occur when the page is visited.


This system avoids most problems that headless apps create, however, in its current form, it has a few gotchas. Fortunately, these issues have been relatively easy to fix, and can often be avoided altogether by simply disabling the app for the pages that are impacted.

Scripts and styles are probably the biggest headache that comes with any method that renders with Javascript, and this system is no exception. Any plugin that enqueues a custom script or style on the front end will not work if the page is loaded with the cache. This is because most plugins only load scripts and styles on pages that need the scripts. This can usually be avoided by forcing any page that utilizes these plugins to load without the cache. That will force the site to load the site normally, which usually makes everything work as-expected.

In my build, Gravity Forms still didn't work, even when the page with the form was loaded normally. This was because Gravity Form's script fired before Alpine rendered the content. This caused Gravity Form to fail.

To get around this, I had two options:

  1. Force all pages that have a Gravity Form to load without Alpine, using a traditional loop. Easy, but not as nice.
  2. Modify how Gravity Forms renders its forms to use Alpine + Gravity Forms REST API. Harder, but nicer.

For this specific problem, I opted to spend a few hours getting Gravity Forms working with Alpine. Since I'm still using WordPress to do my rendering, I didn't have to re-do the rendering portion, just had to modify it a little to use Alpine's event handles. This ended up being significantly easier than what I've had to-do in React, where I had to also re-create the forms in JSX. I didn't have to re-invent the rendering, just had to get submissions working, and it took a lot less effort to achieve.


When I started working on this, I knew that there would be a little more overhead than a basic theme, and a lot less overhead than a site builder. The goal, however, was to minimize the overhead enough to make it a plausible option for our premium clients. This approach offers a lot of performance gains without adding a lot of extra overhead in the process, and most-importantly, provides a traditional-loading safety net for pages that are being naughty.