How to use WordPress as a headless CMS (no frontend)

Wordpress as headless CMS
Headless Buddha statue in Borobudur Temple | 26Isabella via Wikimedia Commons

A client of mine had this problem: they had a very, very old CMS, built in quasi-pure PHP, and quasi-abandoned. Also, part of its codebase was encrypted. It was just garbage.

The client asked me to make it more reliable and possibly use it to expand the business’ operations with new services for new customers.

The CMS handles both news posts and other models (mostly the client’s products). At first I thought I could use WordPress for everything, but that would mean I’d have to develop, among other things, a WordPress theme, which means PHP in the frontend. Bad idea in 2021, imho.

WordPress was (and is) perfect for handling the news post part, media upload, user authentication and a lot of other things. So I decided to go microservices: WordPress as a headless CMS for the news, with Vue for the frontend calling both microservices’ API. Best of all worlds.

The start

Everything is hosted on a single DigitalOcean droplet because any different fashion would be more expensive (for me to explain to the client, to begin with).

We want these three subdomains to talk to each other:

  • www.myclientsite.ooops (www for friends), the Vue frontend;
  • api.myclientsite.ooops (api) for Laravel (a custom CMS);
  • wp.myclientsite.ooops (wp) for headless WordPress.

The Vue frontend is actually a Nuxt.js with server-side rendering, because SEO is critical: the website’s traffic comes firstly from search engines and social networks, so we need Open Graph and stuff. This means that Nuxt will call WordPress, build the page on server and then serve it to the client.

In short, we have to do two things: make Vue talk to WordPress and make WordPress talk to WordPress. Yes, you read it right.

The CORS issue

We have two subdomains: www making requests to wp. This means we have to deal with CORS.

I’m assuming Nginx is already configured (like this, for instance).

We have to add CORS headers. Let’s start by doing something easy: let’s allow every origin.

# /etc/nginx/sites-available/wp.conf
# ... rest of the conf...
location ~ \.php$ {
    add_header 'Access-Control-Allow-Origin' "*" always;
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,DNT,If-Modified-Since,Origin,User-Agent,Keep-Alive,Content-Type,x-csrf-token,x-requested-with' always;
    if ($request_method = OPTIONS) {
        # This part can be improved
        return 204;
    }
    # ... rest of the conf
}

We test the configuration and restart the server…

sudo nginx -t
sudo service nginx restart

And… Uh oh, probably it’s not working, since WordPress (I was using TwentyTwenty theme at the time) is handling the CORS by itself. We have to disable it adding a hook in the theme’s functions.php.

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

Now the requests from www should work, but allowing all origins is not so great. Also, we may want to allow multiple domains, for instance we may want to accept requests from api (and maybe viceversa).

We add a line on top of our .conf file, and then replace our CORS logic with one line:

# /etc/nginx/sites-available/wp.conf
include mapcors;
# ... rest of the conf...
location ~ \.php$ {
    include cors;
    # ... rest of the conf
}

The previous two lines include two files that we are about to create:

# /etc/nginx/mapcors 
map $http_origin $allow_origin {
    ~^https?://(.*\.)?myclientsite.ooops(:\d+)?$ $http_origin;
    default "";
}

With these lines we check if the origin $http_origin request comes from any subdomain of my client’s site, and if so we set the $allow_origin variable to it.

We use map because we shouldn’t use if for stuff like this. Also we add default ""; because we don’t like empty headers.

# /etc/nginx/cors
add_header 'Access-Control-Allow-Origin' "$allow_origin" always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,DNT,If-Modified-Since,Origin,User-Agent,Keep-Alive,Content-Type,x-csrf-token,x-requested-with' always;
if ($request_method = 'OPTIONS') {
        return 204;
}

This configuration can and should be improved, but for now it’s enough. After a restart, the cross domain requests should still work.

The horror

The second part involves making WordPress talking to itself. It’s not a joke, because the change we are about to make will break our WordPress, and we will not be able to create or update our posts.
But it’s a change we need to do.

In WordPress you can declare what the backend address is (site_url) and what the home page of the frontend is (home_url). Usually they are the same, but in our case they are not. We want to use WordPress as a headless CMS, while the frontend is served elsewhere.

In other words, site_url will be wp.myclientsite.ooops while home_url will be www.myclientsite.ooops/news. This is important for the SEO, because some things (like the feed or the sitemaps) use the latter.

To my dismay, this change breaks WordPress API.

You may not know that WordPress uses it’s own API internally. The Gutenberg editor is a React app that uses the REST API for CRUD operations, and the same goes for many popular plugins. This is good and cool.

For some reason, the API calls are sent to home_url, instead of site_url. But home is not home anymore.

This can be easily solved with a filter to be added in WordPress’ functions.php, but still it doesn’t feel quite right…

add_filter('rest_url', function($url) {
        return str_replace(home_url(), site_url(), $url);
});

The cleanup

Now everything should work, and it’s time for some cleanup. If we are using a third party theme on WordPress (like the default one), the changes in functions.php will be overwritten when we update the theme.

Well, about that: since we are serving the frontend elsewhere, we don’t need actually need a real theme anymore, so let’s deactivate it. We will generate a WordPress child theme that will do mostly nothing, leaving only our headless CMS.

We create a folder in WordPress’ themes folder, with just three files.

The first one is style.css: we use it to inform WordPress that this theme is a child of twentytwenty.

/*
Theme Name:   Remove WP FE
Description:  Removes WordPress frontend
Template:     twentytwenty
*/

Next we create a functions.php files with the hook and the filter from above:

<?php
add_action('rest_api_init', function() {
    remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
}, 15);
add_filter('rest_url', function($url) {
        return str_replace(home_url(), site_url(), $url);
});

Finally an index.php that will redirect the requests to the WordPress frontend to the Vue frontend:

<?php
header( "Location: https://www.myclientsite.ooops/news" );

Congratulations! Now you can use WordPress for what it can do best: a pure Content Management System, without the risks and the clunkiness of its PHP frontend.