Transform WordPress into a headless CMS

WordPress is not a headless CMS by default. But with a few tweaks you can use it as a headless CMS. Here are some tips to get you started.


Why use WordPress as a headless CMS?

Lately we use NextJS for our frontend. You can connect NextJS to a headless CMS, like Contentful or Prismic. But a lot of our clients already have a WordPress website. So why not use WordPress as a headless CMS? This way we don’t need to migrate the content to another CMS and the client can continue to use a CMS that they are used to.

Why not use WPGraphQL?

When you look on the internet for a way to use Wordpress as a headless CMS, you will find a lot of articles on how you can use a plugin to extend WordPress with a GraphQL API, like https://wordpress.org/plugins/wp-graphql/. Because we already used NextJS in combination with a JSON:API, we wanted to use an API that is similar. Although the WordPress REST API doesn’t adhere to the JSON:API specification, it isn’t that totally different like GraphQL is. It is well documented and it is already built in WordPress core. Let’s not add another level of abstraction.

Steps for using WordPress as a headless CMS

When it comes to making WordPress headless, there are a few things you need to do. We will go over them one by one.

Disable the WordPress block editor (Gutenberg)

For some time WordPress has been moving to a new editor called the block editor or Gutenberg. This editor is not made for headless setups since it’s tightly coupled to its own theming framework. So we need to disable it. We can do this with the plugin Classic Editor or Disable Gutenberg. I have switched between these two because there was a time that Classic Editor plugin wasn’t updated for new WordPress versions for a while. But it seems it is updated nowadays.

WordPress uses the WP_HOME constant to define the url of the website. In a headless setup we want to use a different url for the frontend and the backend. So we need to change the WP_HOME constant to the frontend url. We also need to change the WP_CONTENT_URL constant, since by default this is based on the WP_HOME url.

For example:

define('WP_HOME', 'https://frontend-url.com');
define('WP_HOME_ADMIN', 'https://cms-url.com') // This is a self defined constant and not part of WordPress core, but we will use it later
define('WP_CONTENT_URL', WP_HOME_ADMIN . '/wp-content');

This will also change the url for the REST API, but that shouldn’t be changed since the REST API is part of the backend. So we need to change the rest url to the backend url. We can do this by adding a filter to the rest_url hook.

add_filter('rest_url', function($url) {
    return str_replace(WP_HOME, WP_HOME_ADMIN, $url);
}, 10, 1);

Nice! Almost there. But we still have a small problem. We can only go into the admin area by going to a specific url, for example https://cms-url.com/wp-admin/ (url path depends on your wordpress installation). When you want to use one of the other login urls like https://cms-url.com/admin or even https://cms-url.com/wp-admin (url without trailing slash), it will be redirected to the frontend url. We can fix this by disabling the canonical redirects.

add_filter('redirect_canonical', '__return_false');

Read the REST API handbook for WordPress

The WordPress REST API is well documented. You can find the documentation at https://developer.wordpress.org/rest-api/. There you will find documentation about all existing endpoints and how to use them. You can also find information about how to create your own endpoints.

Advanced Custom Fields and the REST API

Since version 5.11 ACF supports the WordPress REST API. This way the ACF fields are also available in the REST API. You can add ?acf_format=standard to your API calls to have the meta values formatted like it was configured in ACF. For example if you have a field that is configured to return an image object, you can add ?acf_format=standard to the API call to get the image object instead of the image id.

Publishing your site from WordPress

We use a GitHub action to publish the frontend. You can read more about how we use GitHub actions to deploy our sites in this article. The actual implementation is beyond the scope of this article. But the gist of it is that you can add a custom WordPress admin page and add a ‘Publish’ button which triggers a frontend build.

Scheduled posts for your static generated site

In WordPress you can schedule posts to be published in the future. So we need to trigger the publishing of the static site when that moment arrives. We can do this by using the transition_post_status hook and schedule a single event with the wp_schedule_single_event function.

add_action('transition_post_status', function(string $new_status, string $old_status, WP_Post $post) {
    if ($newStatus === 'publish' && $oldStatus === 'future') {
        schedule_publish($post);
    }
}, 10, 3);

function schedule_publish() {
    $timestamp = strtotime($post->post_date_gmt . ' GMT');
    wp_schedule_single_event($timestamp, 'publish_static_site');
}

function publish_static_site() {
    // Trigger frontend build...
}

Change the preview functionality

Because we don’t use WordPress for the frontend of the site, we need to change the way previewing works. With NextJS we have one preview page that handles all the previews. We can use this page for the WordPress functionality as well. We can change the preview link by using the preview_post_link filter.

add_filter("preview_post_link", function(string $preview_link, WP_Post $post) {
        $query_data = [
            'id' => $post->ID,
            'post_type' => $post->post_type,
        ];
        return WP_HOME . "/preview?" . build_query($query_data);
}, 10, 2);

Now in our client-side code we can use the query parameters to fetch the preview data from the WordPress REST API. To handle previews of guarded posts, like private or scheduled posts, we need to make authorized fetch requests.

Make authorized REST requests with JWT

The WordPress core only allows authenticated requests via session based cookies. But we want to make requests from the frontend. So we need to make authorized requests with a JWT token. There are some plugins that allow you to create JWT tokens, but some are not maintained anymore, and others do too much. And honestly it isn’t that hard to create your own API endpoint for creating personalized tokens using the PHP-JWT package. And allowing authorized requests with these JWT tokens.

For example we can create a token creation endpoint like this:

add_action('rest_api_init', function() {
    register_rest_route('grrr/v1', '/token', [
        'methods' => 'POST',
        'callback' => function(WP_REST_Request $request) {
            $username = $request->get_param('username');
            $password = $request->get_param('password');

            $user = wp_authenticate($username, $password);

            if (is_wp_error($user)) {
                return new WP_REST_Response($user, 401);
            }

            $payload = [
                'iss' => get_bloginfo('url'),
                'iat' => time(),
                'exp' => time() + (60 * 60 * 12); // jwt valid for 12 hours from the issued time
                'nbf' => time(),
                'data' => [
                   'user_id' => $user->ID,
                ]
            ];
            $token = JWT::encode($payload, JWT_AUTH_SECRET_KEY, 'HS256');

            return new WP_REST_Response([
                'token' => $token,
            ], 200);
        },
    ]);
});

This will make a custom API endpoint available at https://cms-url.com/wp-json/grrr/v1/token. This endpoint accepts a username and password and returns a JWT token.

// Ask the user for their username and password via a form.
const { username, password } = formData;

const token = await fetch("https://cms-url.com/wp-json/grrr/v1/token", {
    method: "POST",
    body: JSON.stringify({
        username,
        password,
    }),
    headers: {
        "Content-Type": "application/json",
    },
}).then((response) => response.json());

You can save this token in the localStorage and use it to make authorized requests to the REST API. But first we need to extend the REST API to allow authorized requests via the JWT token. We can do this by utilizing the determine_current_user hook.

use Firebase\JWT\ExpiredException;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\SignatureInvalidException;

add_filter('determine_current_user', function($user_id) {
    // If the user is already determined by WordPress cookies, return the user
    if ($user_id) {
        return $user_id;
    }

    // Get the JWT from the Authorization header
    $authorizationHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? null;
    if (!$authorizationHeader) {
        return $user_id;
    }

    $token = substr($authorizationHeader, 7);

    // Decode the JWT and verify it
    try {
        $decoded = JWT::decode($token, new Key(JWT_AUTH_SECRET_KEY, 'HS256'));
    } catch (SignatureInvalidException $e) {
        return false;
    } catch (ExpiredException $e) {
        return false;
    }

    // Check if the user exists
    $user = get_user_by('id', $decoded->data->user_id);
    return $user->ID ?? false;
}, 20);

Now we can make authorized requests to the REST API by adding the JWT token to the Authorization header.

const posts = await fetch(`https://cms-url.com/wp-json/wp/v2/posts/{post_id}`, {
    headers: {
        Authorization: `Bearer ${token}`,
    },
}).then((response) => response.json());

Use this in Next.js to implement your preview page.

Cache the REST API

We highly recommend to cache the REST API. One plugin that we like to use for this is WP REST Cache. There could be other plugins but we like this one since it is specifically made for caching the REST API. With this plugin active the build time of the static site will be a lot faster on consecutive builds.

Final words

We hope this article will help you to get started with using WordPress as a headless CMS. Keep in mind that a lot of plugins are not made for headless setups, especially plugins that add functionality to the frontend, but the core functionality of the WordPress CMS is now covered with these tweaks.