I believe that a traditional WordPress theme should be able to work as effectively as a static site or a headless web app. The overwhelming majority of WordPress websites are built with a good ol’ fashioned WordPress theme. Most of them even have good caching layers, and dependency optimizations that make these sites run reasonably fast. But as developers, we have accomplished ways to create better results for our websites. Using a headless WordPress has allowed many sites to have faster load speeds, better user interactions, and seamless transitions between pages.

The problem? Maintenance. Let me show you another possibility!

Let’s start by defining what I mean by “Traditional” WordPress, “Headless” WordPress, and then “Nearly Headless” WordPress.

Traditional WordPress websites

Traditionally, a WordPress website is built using PHP to render the HTML markup that is rendered on the page. Each time a link is clicked, the browser sends another request to the server, and PHP renders the HTML markup for the site that was clicked.

This is the method that most sites use. It’s the easiest to maintain, has the least complexity in the tech, and with the right server-side caching tools it can perform fairly well. The issue is, since it is a traditional website, it feels like a traditional website. Transitions, effects, and other stylish, modern features tend to be more difficult to build and maintain in this type of site.


  1. The site is easy to maintain.
  2. The tech is relatively simple.
  3. There is great compatibility with WordPress plugins.


  1. Your site may feel a little dated as society expects app-like experiences in the browser.
  2. JavaScript tends to be a little harder to write and maintain since the site isn’t using a JavaScript framework to control the site’s behavior.
  3. Traditional websites tend to run slower than headless and nearly headless options.

Headless WordPress websites

A headless WordPress website uses modern JavaScript and some kind of server-side RESTful service, such as the WordPress REST API or GraphQL. Instead of building, and rendering the HTML in PHP, the server sends minimal HTML and a big ol’ JavaScript file that can handle rendering any page on the site. This method loads pages much faster, and opens up the opportunity to create really cool transitions between pages, and other interesting things.

No matter how you spin it, most headless WordPress websites require a developer on-hand to make any significant change to the website. Want to install a forms plugin? Sorry, you probably need a developer to set that up. Want to install a new SEO plugin? Nope, going to need a developer to change the app. Wanna use that fancy block? Too bad — you’re going to need a developer first.


  1. The website itself will feel modern, and fast.
  2. It’s easy to integrate with other RESTful services outside of WordPress.
  3. The entire site is built in JavaScript, which makes it easier to build complex websites.


  1. You must re-invent a lot of things that WordPress plugins do out of the box for you.
  2. This set up is difficult to maintain.
  3. Compared to other options, hosting is complex and can get expensive.

See “WordPress and Jamstack” for a deeper comparison of the differences between WordPress and static hosting.

I love the result that headless WordPress can create. I don’t like the maintenance. What I want is a web app that allows me to have fast load speeds, transitions between pages, and an overall app-like feel to my site. But I also want to be able to freely use the plugin ecosystem that made WordPress so popular in the first place. What I want is something headless-ish. Nearly headless.

I couldn’t find anything that fit this description, so I built one. Since then, I have built a handful of sites that use this approach, and have built the JavaScript libraries necessary to make it easier for others to create their own nearly headless WordPress theme.

Introducing Nearly Headless WordPress

Nearly headless is a web development approach to WordPress that gives you many of the app-like benefits that come with a headless approach, as well as the ease of development that comes with using a traditional WordPress theme. It accomplishes this with a small JavaScript app that will handle the routing and render your site much like a headless app, but has a fallback to load the exact same page with a normal WordPress request instead. You can choose which pages load using the fallback method, and can inject logic into either the JavaScript or the PHP to determine if the page should be loaded like this.

You can see this in action on the demo site I built to show off just what this approach can do.

For example, one of the sites implementing this method uses a learning management system called LifterLMS to sell WordPress courses online. This plugin has built-in e-commerce capabilities, and sets up the interface needed to host and place course content behind a paywall. This site uses a lot of LifterLMS’s built-in functionality to work — and a big part of that is the checkout cart. Instead of re-building this entire page to work inside my app, I simply set it to load using the fallback method. Because of this, this page works like any old WordPress theme, and works exactly as intended as a result — all without me re-building anything.


  1. This is easy to maintain, when set-up.
  2. The hosting is as easy as a typical WordPress theme.
  3. The website feels just as modern and fast as a headless website.


  1. You always have to think about two different methods to render your website.
  2. There are limited choices for JavaScript libraries that are effective with this method.
  3. This app is tied very closely to WordPress, so using third party REST APIs is more-difficult than headless.

How it works

For something to be nearly headless, it needs to be able to do several things, including:

  1. load a page using a WordPress request,
  2. load a page using JavaScript,
  3. allow pages to be identical, regardless of how they’re rendered,
  4. provide a way to know when to load a page using JavaScript, or PHP, and
  5. Ensure 100% parity on all routed pages, regardless of if it’s rendered with JavaScript or PHP.

This allows the site to make use of progressive enhancement. Since the page can be viewed with, or without JavaScript, you can use whichever version makes the most sense based on the request that was made. Have a trusted bot crawling your site? Send them the non-JavaScript version to ensure compatibility. Have a checkout page that isn’t working as-expected? Force it to load without the app for now, and maybe fix it later.

To accomplish each of these items, I released an open-source library called Nicholas, which includes a pre-made boilerplate.

Keeping it DRY

The biggest concern I wanted to overcome when building a nearly-headless app is keeping parity between how the page renders in PHP and JavaScript. I did not want to have to build and maintain my markup in two different places — I wanted a single source for as much of the markup as possible. This instantly limited which JavaScript libraries I could realistically use (sorry React!). With some research, and a lot of experimentation, I ended up using AlpineJS. This library kept my code reasonably DRY. There’s parts that absolutely have to be re-written for each one (loops, for example), but most of the significant chunks of markup can be re-used.

A single post template rendered with PHP might look like something like this:

if ( have_posts() ) { while ( have_posts() ) { the_post(); if ( is_singular() ) { echo nicholas()->templates()->get_template( 'index', 'post', [ 'content' => Nicholas::get_buffer( 'the_content' ), 'title' => Nicholas::get_buffer( 'the_title' ), ] ); } }

That same post template rendered in JavaScript, using Alpine:

<template x-for="(post, index) in $store.posts" :key="index"> <?= nicholas()->templates()->get_template( 'index', 'post' ) ?>

Both of them use the same PHP template, so all of the code inside the actual loop is DRY:

$title = $template->get_param( 'title', '' ); // Get the title that was passed into this template, fallback to empty string.$content = $template->get_param( 'content', '' ); // Get the content passed into this template, fallback to empty string. ?>
<article x-data="theme.Post(index)"> <!-- This will use the alpine directive to render the title, or if it's in compatibility mode PHP will fill in the title directly --> <h1 x-html="title"><?= $title ?></h1> <!-- This will use the Alpine directive to render the post content, or if it's in compatibility mode, PHP will fill in the content directly --> <div class="content" x-html="content"><?= $content ?></div>

Related: This Alpine.js approach is similar in spirit to the Vue.js approach covered in “How to Build Vue Components in a WordPress Theme” by Jonathan Land.

Detect when a page should run in compatibility mode

“Compatibility mode” allows you to force any request to load without the JavaScript that runs the headless version of the site. When a page is set to load using compatibility mode, the page will be loaded using nothing but PHP, and the app script never gets enqueued. This allows “problem pages” that don’t work as-expected with the app to run without needing to re-write anything.

There are several different ways you can force a page to run in compatibility mode — some require code, and some don’t. Nicholas adds a toggle to any post type that makes it possible to force a post to load in compatibility mode.

Along with this, you can manually add any URL to force it to load in compatibility mode inside the Nicholas settings.

These are a great start, but I’ve found that I can usually detect when a page needs to load in compatibility mode automatically based on what blocks are stored in a post. For example, let’s say you have Ninja Forms installed on your site, and you want to use the validation JavaScript they provide instead of re-making your own. In this case, you would have to force compatibility mode on any page that has a Ninja Form on it. You could manually go through and add each URL as you need them, or you can use a query to get all of the content that has a Ninja Forms block on the page. Something like this:

add_filter( 'nicholas/compatibility_mode_urls', function ( $urls ) { // Filter Ninja Forms Blocks $filtered_urls = Nicholas::get_urls_for_query( [ 'post_type' => 'any', 's' => 'wp:ninja-forms/form', // Find Ninja Forms Blocks ] ); return array_merge( $urls, $filtered_urls );
} );

That automatically adds any page with a Ninja Forms block to the list of URLs that will load using compatibility mode. This is just using WP_Query arguments, so you could pass anything you want here to determine what content should be added to the list.

Extending the app

Under the hood, Nicholas uses a lightweight router that can be extended using a middleware pattern much like how an Express app handles middleware. When a clicked page is routed, the system runs through each middleware item, and eventually routes the page. By default, the router does nothing; however, it comes with several pre-made middleware pieces that allows you to assemble the router however you see-fit.

A basic example would look something like this:

// Import WordPress-specific middleware
import { updateAdminBar, validateAdminPage, validateCompatibilityMode
} from 'nicholas-wp/middlewares' // Import generic middleware
import { addRouteActions, handleClickMiddleware, setupRouter, validateMiddleware
} from "nicholas-router"; // Do these actions, in this order, when a page is routed.
addRouteActions( // First, validate the URL validateMiddleware, // Validate this page is not an admin page validateAdminPage, // Validate this page doesn't require compatibility mode validateCompatibilityMode, // Then, we Update the Alpine store updateStore, // Maybe fetch comments, if enabled fetchComments, // Update the history updateHistory, // Maybe update the admin bar updateAdminBar
) // Set up the router. This also uses a middleware pattern.
setupRouter( // Setup event listener for clicks handleClickMiddleware

From here, you could extend what happens when a page is routed. Maybe you want to scan the page for code to highlight, or perhaps you want to change the content of the <head> tag to match the newly routed page. Maybe even introduce a caching layer. Regardless of what you need to-do, adding the actions needed is as simple as using addRouteAction or setupRouter.

Next steps

This was a brief overview of some of the key components I used to implement the nearly headless approach. If you’re interested in going deeper, I suggest that you take my course at WP Dev Academy. This course is a step-by-step guide on how to build a nearly headless WordPress website with modern tooling. I also suggest that you check out my nearly headless boilerplate that can help you get started on your own project.

Similar Posts