Web development
28 mins reading time

WordPress as headless CMS

Toine Kamps

Web developer

WordPress is still the Content Management System (CMS) of choice for building websites. The market share of WordPress within comparable CMS systems was even 63.2% in June 2023. With an absolute user rate of 43.1% of all websites. Those are huge numbers.

Why is WordPress still so popular?

Its success lies in the ease of use that allows anyone to add, edit and publish content such as texts, images and videos. But that is no longer unique in 2023. There are now countless CMS systems with perhaps even better user experiences. The biggest advantage of WordPress is that in recent years (the first version came out in 2003) it has built a huge ecosystem of plugins, themes and integrations. Surrounded by a huge user community. A plugin or other solution can be found for any desired additional functionality.

This was exactly the reason why we wanted to explore the possibility with Clarify to also offer WordPress as a headless CMS.

What exactly is a headless CMS?

A headless CMS is a type of Content Management System where content management is decoupled from its visual presentation. Instead of presenting the content through the same system through templates with predefined layouts and styles, the content is made available through an API (Application Programming Interface). Which can then be consumed as a data source in multiple applications.

As a result, you only need to use the headless CMS to manage and store your content in a central environment, while presenting it to the end user in many different ways. For example in a website or a separate mobile app. This offers enormous flexibility in visual presentation and performance. For example, you can visualize the content in a website with a front-end framework of your choice, such as React or Angular. And at the same time also in a native iOS app with a completely different layout.

Benefits of a headless CMS
Better performances

Because you are no longer tied to the WordPress framework itself to visualize your frontend, you can use all other modern frameworks and tools that are more focused on performance and user experience. Our favorite framework for this is Next.js.

Better scalability

You can completely determine and set up the architecture of your disconnected frontend yourself. By using Docker, for example, you can easily scale up your application and even distribute it over multiple servers. This would be much more difficult with WordPress itself.

Better security

Partly due to the success of WordPress, it is also a popular target for hackers and DDoS attacks. Since the headless CMS is completely shielded from visitors, it offers much more protection against these types of attacks. You also have many more options to further secure your frontend yourself by using services such as Cloudflare.

Different channels

As mentioned above, you can use your content on different channels at the same time. Which is of course much more efficient than having to manage content in multiple places.

Disadvantages of a headless CMS
More work and therefore more expensive

Since you also have to develop a separate frontend in addition to the headless CMS, you will lose more time. This also makes a headless CMS setup a bit more expensive than a more traditional WordPress setup.

Inconsistent user experience

Using your content in disconnected and sometimes even multiple places can result in an inconsistent user experience.

No visual editor

Since the visual presentation of your content depends on where it is displayed, the new Gutenberg editor of Wordpress that was launched in 2018 has little added value. The traditional WYSIWYG editor can also cause inconsistency in the actual presentation of the content.

Set up Wordpress as a headless CMS

WordPress was once developed as a blogging tool that allowed you to quickly and out-of-the-box create a blog website. It has since grown into so much more than that. But you notice here and there that it's still developed with that mindset at its core. In 2003, the term headless CMS did not yet exist. And even though WordPress now has a well-functioning REST API with which you can basically use the CMS headless. You notice in many places in the core functionalities of the system that it was never actually developed for this.

But is it suitable to use it headless? Of course! We want to help you with a number of handy code snippets to make the CMS and the REST API more suitable for a headless setup.

1. Disable Gutenberg

One of the first WordPress features we want to disable in a headless setup is the Gutenberg editor. Since it was really developed as a visual content editor, this is of no use to us in a headless system. Since the content can look different everywhere. Plus, it takes extra time while developing the backend and frontend.

You can easily disable the Gutenberg editor with one line of code:

1add_filter('use_block_editor_for_post', '__return_false', 10);
2. Relative URLs

What WordPress is not so well equipped for is the permalinks. Each page, post or post type gets its own permalink structure, but it is always absolute and based on the Site Address (URL) in WordPress. As Clarify, we usually choose to use a separate subdomain and separate server for the CMS.

Suppose you want to create a website and use the domain example.test for the frontend, then it is a logical choice to use cms.example.test or admin.example.test for the headless CMS. This domain is then also used as the Site address (URL) in WordPress.

A typical standard REST API response for a standard page using the endpoint https://cms.example.test/wp-json/wp/v2/pages/<id> will therefore look like this:

1{
2    "author": 2,
3    "comment_status": "closed",
4    "content": {
5        "protected": false,
6        "rendered": "Arcu curabitur nam nulla iaculis mauris. Senectus <a href=\"https://cms.example.test/nascetur-posuere/\">nascetur posuere</a> leo consectetuer pretium. Lacus commodo letius nulla sodales vulputate viverra mattis. Vestibulum senectus mus donec id mattis placerat netus at justo vivamus."
7    },
8    "date": "2023-02-17T14:59:22",
9    "date_gmt": "2023-02-17T14:59:22",
10    "excerpt": {
11        "protected": false,
12        "rendered": ""
13    },
14    "featured_media": 0,
15    "guid": {
16        "rendered": "https://cms.example.test?page_id=26"
17    },
18    "id": 26,
19    "link": "https://cms.example.test/example-page/",
20    "menu_order": 0,
21    "meta": [],
22    "modified": "2023-04-10T21:41:42",
23    "modified_gmt": "2023-04-10T19:41:42",
24    "parent": 0,
25    "ping_status": "closed",
26    "slug": "example-page",
27    "status": "publish",
28    "template": "",
29    "title": {
30        "rendered": "Example Page"
31    },
32    "type": "page"
33}

To make all link properties and any permalinks in the content in the REST API responses relative, we can use the rest_prepare_{$this->post_type} filter. We create a separate PHP class RestAPI for all these kinds of REST API tweaks:

1namespace App\Controllers;
2
3class RestAPI
4{
5	/**
6	 * Make all default links relative
7	 */
8	public static function rest_prepare_headless_data($response, $post, $request) {
9		if (isset($response->data['content']['rendered'])) {
10			$content  = $response->data['content']['rendered'];
11			$headless = \App\make_relative_url($content);
12
13			$response->data['content']['rendered'] = $headless;
14		}
15
16		if (isset($response->data['link'])) {
17			$link     = $response->data['link'];
18			$headless = \App\make_relative_url($link);
19
20			$response->data['link'] = $headless;
21		}
22
23		return $response;
24	}
25}

Add the filters to your functions.php:

1add_filter('rest_prepare_post', ['App\Controllers\RestAPI', 'rest_prepare_headless_data'], 10, 3); 
2add_filter('rest_prepare_page', ['App\Controllers\RestAPI', 'rest_prepare_headless_data'], 10, 3);

All auxiliary functions such as make_relative_url we put in one helpers.php file:

1/**
2 * Convert absolute backend url's to relative url's
3 */
4function make_relative_url($data = null) {
5	if (!$data) {
6		return;
7	}
8
9	if (isset($_SERVER['WP_HOME'])) {
10		if (is_string($data)) {
11			$pattern  = '~'. preg_quote($_SERVER['WP_HOME']) .'(?!/app)~i';
12			$relative = preg_replace($pattern, '', $data);
13
14			return $relative;
15		}
16
17		if (is_array($data)) {
18			$relative = [];
19			foreach ($data as $key => $value) {
20				$relative[$key] = make_relative_url($value);
21			}
22
23			return $relative;
24		}
25	}
26
27	return $data;
28}

In our case, the WP_HOME value filled by means of an environment variable, but you can also use the get_home_url() function for this. You don't want to make any links to uploads, plugins and themes (which in our case are in a separate app folder) relative. Here you can put a PHP regex pattern like '~'. preg_quote($_SERVER['WP_HOME']) .'(?!/app)~i' for use.

After implementing these filters, your response should look like this:

1{
2    "author": 2,
3    "comment_status": "closed",
4    "content": {
5        "protected": false,
6        "rendered": "Arcu curabitur nam nulla iaculis mauris. Senectus <a href=\"/nascetur-posuere/\">nascetur posuere</a> leo consectetuer pretium. Lacus commodo letius nulla sodales vulputate viverra mattis. Vestibulum senectus mus donec id mattis placerat netus at justo vivamus."
7    },
8    "date": "2023-02-17T14:59:22",
9    "date_gmt": "2023-02-17T14:59:22",
10    "excerpt": {
11        "protected": false,
12        "rendered": ""
13    },
14    "featured_media": 0,
15    "guid": {
16        "rendered": "https://cms.example.test?page_id=26"
17    },
18    "id": 26,
19    "link": "/example-page/",
20    "menu_order": 0,
21    "meta": [],
22    "modified": "2023-04-10T21:41:42",
23    "modified_gmt": "2023-04-10T19:41:42",
24    "parent": 0,
25    "ping_status": "closed",
26    "slug": "example-page",
27    "status": "publish",
28    "template": "",
29    "title": {
30        "rendered": "Example Page"
31    },
32    "type": "page"
33}

All absolute permalinks in the link and content properties have now become relative.

3. Shared data

A common pattern for a headless CMS is that you need some data on every page. Think of the WordPress site title and slogan, menus, footer content or certain CMS settings. Here we have a custom example/v1/common endpoint created. In this endpoint we retrieve in our example all WordPress navigation menus, all Advanced Custom Fields options and all useful WordPress settings:

1namespace App\Controllers;
2
3class RestAPI
4{
5	/**
6	 * Register custom Rest API endpoints
7	 */
8	public static function register_api_routes()
9	{
10		register_rest_route(
11			'example/v1',
12			'/common', [
13				'methods'             => 'GET',
14				'callback'            => ['App\Controllers\RestAPI', 'get_common_data'],
15				'permission_callback' => '__return_true'
16			]
17		);
18	}
19
20	/**
21	 * Get common data
22	 */
23	public static function get_common_data($request)
24	{
25		$common = [
26			'menus'    => \App\Controllers\Menus::get_nav_menus(), // Get WP Nav Menus
27			'options'  => get_fields('options'),                   // Get ACF Options
28			'settings' => self::get_global_settings($request)      // Get WP Settings
29		];
30
31		return $common;
32	}
33
34	/**
35	 * Get global site settings
36	 */
37	public static function get_global_settings($request)
38	{
39		$settings = [
40			'name'               => get_option('blogname'),
41			'description'        => get_option('blogdescription'),
42			'home'               => \App\make_relative_url(home_url('/')),
43			'date_format'        => get_option('date_format'),
44			'time_format'        => get_option('time_format'),
45			'gmt_offset'         => get_option('gmt_offset'),
46			'timezone'           => get_option('timezone_string'),
47			'start_of_week'      => get_option('start_of_week'),
48			'posts_per_page'     => get_option('posts_per_page'),
49			'wp_max_upload_size' => wp_max_upload_size()
50		];
51
52		return $settings;
53	}
54}

Call the function from your functions.php:

1add_action('rest_api_init', ['App\Controllers\RestAPI', 'register_api_routes']);

To retrieve and format all Wordpress menus for a headless setup, we created a separate PHP class Menus:

1namespace App\Controllers;
2
3class Menus
4{
5	/**
6	 * Construct Menu Item for WP Rest response
7	 */
8	public static function add_menu_item($item = null)
9	{
10		$menu_item = [];
11
12		if (!empty($item)) {
13			$menu_item['ID']    = $item->ID;
14			$menu_item['title'] = $item->title;
15			$menu_item['url']   = \App\make_relative_url($item->url);
16
17			if ($item->classes) {
18				$menu_item['classes'] = $item->classes;
19			}
20
21			if ($item->description) {
22				$menu_item['desc'] = $item->description;
23			}
24
25			if ($item->target) {
26				$menu_item['target'] = $item->target;
27			}
28
29			if ($item->object) {
30				$menu_item['object'] = $item->object;
31			}
32
33			if ($item->object_id && $item->object !== 'custom') {
34				$post        = get_post($item->object_id);
35				$status      = $post->post_status;
36				$parent      = $post->post_parent ? get_post($post->post_parent) : null;
37				$parent_post = $post->post_parent;
38
39				if ($parent) {
40					$parent_post = [
41						'id'   => (int) $parent->ID,
42						'slug' => $parent->post_name
43					];
44				}
45
46				if ($status === 'private') {
47					$menu_item['url'] = get_home_url() . ($parent ? '/' . $parent->post_name : '') .'/'. $post->post_name;
48				}
49
50				$menu_item['object'] = [
51					'id'     => (int) $item->object_id,
52					'slug'   => $post->post_name,
53					'status' => $status,
54					'parent' => $parent_post
55				];
56			}
57		}
58
59		return $menu_item;
60	}
61
62	/**
63	 * Get WP Nav Menus
64	 */
65	public static function get_nav_menus()
66	{
67		$nav_menus = [];
68		$locations = get_nav_menu_locations();
69
70		foreach ($locations as $name => $menu_id) {
71			$menu_array = wp_get_nav_menu_items($menu_id);
72			$menu_items = [];
73
74			if ($menu_array) {
75				foreach ($menu_array as $item) {
76					// Parents
77					if (empty($item->menu_item_parent)) {
78						$menu_items[$item->ID] = self::add_menu_item($item);
79					// Children
80					} else {
81						$parent_id    = $item->menu_item_parent;
82						$parent_exist = get_post_status($parent_id) !== FALSE;
83
84						if ($parent_exist) {
85							$submenu = [];
86							$submenu[$item->ID] = self::add_menu_item($item);
87							$menu_items[$parent_id]['children'][] = $submenu[$item->ID];
88						}
89					}
90				}
91
92				$nav_menu = array_values($menu_items);
93				$nav_menus[$name] = $nav_menu;
94			}
95		}
96
97		return $nav_menus;
98	}
99}

This common data is now available via the custom endpoint:

1https://cms.example.test/wp-json/example/v1/common/

and depending on the number of menus and ACF options you define will look something like this:

1{
2    "menus": {
3        "header_menu": [
4            {
5                "ID": 1058,
6                "classes": [""],
7                "object": {
8                    "id": 21,
9                    "parent": 0,
10                    "slug": "contact",
11                    "status": "publish"
12                },
13                "title": "Contact",
14                "url": "/contact/"
15            }
16        ],
17        "main_menu": [
18            {
19                "ID": 513,
20                "classes": [""],
21                "object": {
22                    "id": 17,
23                    "parent": 0,
24                    "slug": "over-ons",
25                    "status": "publish"
26                },
27                "title": "Over ons",
28                "url": "/over-ons/"
29            },
30            {
31                "ID": 514,
32                "classes": [""],
33                "object": {
34                    "id": 12,
35                    "parent": 0,
36                    "slug": "werk",
37                    "status": "publish"
38                },
39                "title": "Werk",
40                "url": "/werk/"
41            },
42            {
43                "ID": 1063,
44                "children": [
45                    {
46                        "ID": 1072,
47                        "classes": [""],
48                        "object": {
49                            "id": 25,
50                            "parent": 0,
51                            "slug": "visual-consultancy",
52                            "status": "publish"
53                        },
54                        "title": "Visual consultancy",
55                        "url": "/diensten/visual-consultancy/"
56                    },
57                    {
58                        "ID": 1064,
59                        "classes": [""],
60                        "object": {
61                            "id": 26,
62                            "parent": 0,
63                            "slug": "animatie",
64                            "status": "publish"
65                        },
66                        "title": "Animatie",
67                        "url": "/diensten/animatie/"
68                    },
69                    {
70                        "ID": 1066,
71                        "classes": [""],
72                        "object": {
73                            "id": 24,
74                            "parent": 0,
75                            "slug": "datavisualisatie",
76                            "status": "publish"
77                        },
78                        "title": "Datavisualisatie",
79                        "url": "/diensten/datavisualisatie/"
80                    }
81                ],
82                "classes": [""],
83                "object": "custom",
84                "title": "Diensten",
85                "url": "#"
86            },
87            {
88                "ID": 512,
89                "classes": [""],
90                "object": {
91                    "id": 19,
92                    "parent": 0,
93                    "slug": "werken-bij",
94                    "status": "publish"
95                },
96                "title": "Werken bij",
97                "url": "/werken-bij/"
98            },
99            {
100                "ID": 1062,
101                "classes": [""],
102                "object": {
103                    "id": 1059,
104                    "parent": 0,
105                    "slug": "artikelen",
106                    "status": "publish"
107                },
108                "title": "Artikelen",
109                "url": "/artikelen/"
110            },
111            {
112                "ID": 511,
113                "classes": [""],
114                "object": {
115                    "id": 21,
116                    "parent": 0,
117                    "slug": "contact",
118                    "status": "publish"
119                },
120                "title": "Contact",
121                "url": "/contact/"
122            }
123        ],
124        "service_menu": [
125            {
126                "ID": 516,
127                "classes": [""],
128                "object": {
129                    "id": 15,
130                    "parent": 0,
131                    "slug": "algemene-voorwaarden",
132                    "status": "publish"
133                },
134                "title": "Algemene voorwaarden",
135                "url": "/algemene-voorwaarden/"
136            },
137            {
138                "ID": 517,
139                "classes": [""],
140                "object": {
141                    "id": 3,
142                    "parent": 0,
143                    "slug": "privacybeleid",
144                    "status": "publish"
145                },
146                "title": "Privacybeleid",
147                "url": "/privacybeleid/"
148            }
149        ]
150    },
151    "options": {
152        "contact_details": {
153            "address": "Leidsekade 90",
154            "city": "Amsterdam",
155            "company": "My Company",
156            "department": "Amsterdam",
157            "email": "[email protected]",
158            "phone": "+31 20 123 45 67"
159        },
160        "four_oh_four": {
161            "home_link": "Terug naar home",
162            "title": "Deze pagina kunnen we niet vinden."
163        },
164        "social_media": {
165            "channels": [
166                {
167                    "icon": {
168                        "class": "fa-brands fa-instagram",
169                        "element": "<i class=\"fa-brands fa-instagram\" aria-hidden=\"true\"></i>",
170                        "hex": "\\f16d",
171                        "id": "instagram",
172                        "prefix": "fa-brands",
173                        "style": "brands",
174                        "unicode": ""
175                    },
176                    "name": "Instagram",
177                    "url": "https://www.instagram.com/mycompany/"
178                },
179                {
180                    "icon": {
181                        "class": "fa-brands fa-linkedin-in",
182                        "element": "<i class=\"fa-brands fa-linkedin-in\" aria-hidden=\"true\"></i>",
183                        "hex": "\\f0e1",
184                        "id": "linkedin-in",
185                        "prefix": "fa-brands",
186                        "style": "brands",
187                        "unicode": ""
188                    },
189                    "name": "Linkedin",
190                    "url": "https://www.linkedin.com/company/mycompany/"
191                },
192                {
193                    "icon": {
194                        "class": "fa-brands fa-twitter",
195                        "element": "<i class=\"fa-brands fa-twitter\" aria-hidden=\"true\"></i>",
196                        "hex": "\\f099",
197                        "id": "twitter",
198                        "prefix": "fa-brands",
199                        "style": "brands",
200                        "unicode": ""
201                    },
202                    "name": "Twitter",
203                    "url": "https://twitter.com/mycompany/"
204                }
205            ]
206        }
207    },
208    "settings": {
209        "date_format": "F j, Y",
210        "description": "Just another wordpress site",
211        "gmt_offset": 2,
212        "home": "/",
213        "name": "My Site",
214        "posts_per_page": "10",
215        "start_of_week": "1",
216        "time_format": "g:i a",
217        "timezone": "Europe/Amsterdam",
218        "wp_max_upload_size": 104857600
219    }
220}
4. Front page

The first thing you often want to do in your frontend is to define the first entry point or front page. In a classic WordPress setup, this is easy by creating a template file called either front-page.php for a static page or home.php for your latest posts.

Unfortunately, there is no default endpoint for this in the REST API without all the id to know from the front page you have set. We also make a custom here example/v1/front-page endpoint that checks what is set as the front page and returns the corresponding response. We define this endpoint in a new PHP class Pages:

1namespace App\Controllers;
2
3class Pages
4{
5	/**
6	 * Register custom Rest API endpoints
7	 */
8	public static function register_api_routes()
9	{
10		// For front page
11		register_rest_route(
12			'example/v1',
13			'/front-page', [
14				'methods'             => 'GET',
15				'callback'            => ['App\Controllers\Pages', 'get_front_page'],
16				'permission_callback' => '__return_true'
17			]
18		);
19	}
20
21	/**
22	 *  Get page set as front page in settings
23	 */
24	public static function get_front_page($data)
25	{
26		$page_id = get_option('page_on_front');
27
28		if ($page_id) {
29			$request  = new \WP_REST_Request('GET', '/wp/v2/pages/' . $page_id);
30			$response = rest_do_request($request);
31
32			if ($response->is_error()) {
33				$error         = $response->as_error();
34				$error_code    = $error->get_error_code();
35				$error_message = $error->get_error_message();
36				$error_data    = $error->get_error_data();
37
38				return new \WP_Error($error_code, $error_message, $error_data);
39			}
40
41			$data = $response->get_data();
42			return new \WP_REST_Response($data, true);
43		} else {
44			return null;
45		}
46	}
47}

Call the function from your functions.php:

1add_action('rest_api_init', ['App\Controllers\Pages', 'register_api_routes']);

This front-page data is now available through the custom endpoint:

1https://cms.example.test/wp-json/example/v1/front-page
5. Page based on its path

If your frontend will consist of several pages, you will have to implement the route logic yourself. In Next.js, for example, this is easily done with dynamic catch-all routes. In a classic WordPress setup you can easily show the right page with a page.php template file. However, in a headless setup you have to identify the desired page based on a parameter that you send to a REST API call. The most logical thing is to do that based on the incoming URL in the frontend.

For example, if you are in the frontend on the page example.test/over-ons/ comes in, the page will have to be fetched based on the slug over-ons.For pages with a set main page in WordPress, this will become a path, e.g. for example.test/over-ons/team becomes this over-ons/team.

Unfortunately, WordPress does not have a standard REST API endpoint for this without the page in advance id to know, so we also add our own custom endpoint here example/v1/page/(?P[a-zA-Z0-9-/_]+) to the Pages class:

1namespace App\Controllers;
2
3class Pages
4{
5	/**
6	 * Register custom Rest API endpoints
7	 */
8	public static function register_api_routes()
9	{
10		// For front page
11		register_rest_route(
12			'example/v1',
13			'/front-page', [
14				'methods'             => 'GET',
15				'callback'            => ['App\Controllers\Pages', 'get_front_page'],
16				'permission_callback' => '__return_true'
17			]
18		);
19
20		// Get page by path
21		register_rest_route(
22			'example/v1',
23			'/page/(?P[a-zA-Z0-9-/_]+)', [
24				'methods'             => 'GET',
25				'callback'            => ['App\Controllers\Pages', 'get_page_by_path'],
26				'permission_callback' => '__return_true',
27				'args'                => [
28					'page_path' => [
29						'validate_callback' => function($page_path, $request, $key) {
30							return is_string($page_path);
31						}
32					]
33				]
34			]
35		);
36	}
37
38	/**
39	 *  Get page set as front page in settings
40	 */
41	public static function get_front_page($data)
42	{
43		$page_id = get_option('page_on_front');
44
45		if ($page_id) {
46			$request  = new \WP_REST_Request('GET', '/wp/v2/pages/' . $page_id);
47			$response = rest_do_request($request);
48
49			if ($response->is_error()) {
50				$error         = $response->as_error();
51				$error_code    = $error->get_error_code();
52				$error_message = $error->get_error_message();
53				$error_data    = $error->get_error_data();
54
55				return new \WP_Error($error_code, $error_message, $error_data);
56			}
57
58			$data = $response->get_data();
59			return new \WP_REST_Response($data, true);
60		} else {
61			return null;
62		}
63	}
64
65	/**
66	 * Get page by current path
67	 */
68	public static function get_page_by_path($request)
69	{
70		$path    = isset($request['page_path']) ? $request['page_path'] : null;
71		$page    = get_page_by_path($path, OBJECT, 'page');
72		$page_id = $page ? $page->ID : null;
73
74		if ($page_id) {
75			$request  = new \WP_REST_Request('GET', '/wp/v2/pages/' . $page_id);
76			$response = rest_do_request($request);
77
78			if ($response->is_error()) {
79				$error         = $response->as_error();
80				$error_code    = $error->get_error_code();
81				$error_message = $error->get_error_message();
82				$error_data    = $error->get_error_data();
83
84				return new \WP_Error($error_code, $error_message, $error_data);
85			}
86
87			$data = $response->get_data();
88			return new \WP_REST_Response($data, true);
89		} else {
90			return null;
91		}
92	}
93}

The (?P[a-zA-Z0-9-/_]+) section defines the page_path variable and specifies that it may consist of a slug or pad. In the frontend you can now retrieve any desired page by executing the following calls:

1https://cms.example.test/wp-json/example/v1/page/over-ons
2https://cms.example.test/wp-json/example/v1/page/over-ons/team
6. Images

Managing images in a headless setup is also a tricky subject, ideally you want to keep using the WordPress media library for all your different frontend channels, so all your media stays in one place. You can also offload all your media from WordPress to an S3 bucket using a plugin such as WP Offload Media.

The disadvantage of most content management systems is that they scale and possibly crop all uploaded images to predefined sizes and proportions, also in WordPress. To be able to scale and crop individual images on-the-fly, we have developed the plugin Responsive Pics with Clarify. We wrote an extensive article about this in 2021 Resize images with Responsive Pics.

Since the frontend determines how large an image should appear in a certain layout, you will want to request a certain size from the frontend. In our plugin we have also added a REST API that provides this. Documentation on this can be found here. This API is also based on the unique WordPress id of a media item. It is therefore important that the REST API responses from WordPress contain at least the image id, but in some cases you also want the url and sizes of the original image (think of aspect ratio boxes, for example).


For this we also add a number of filters in WordPress. In step 2 we already had the rest_prepare_headless_data function defined. To this we can add:

1/**
2 * Make all default links headless
3 */
4public static function rest_prepare_headless_data($response, $post, $request)
5{
6	if (isset($response->data['content']['rendered'])) {
7		$content  = $response->data['content']['rendered'];
8		$headless = \App\make_relative_url($content);
9
10		$response->data['content']['rendered'] = $headless;
11	}
12
13	if (isset($response->data['link'])) {
14		$link     = $response->data['link'];
15		$headless = \App\make_relative_url($link);
16
17		$response->data['link'] = $headless;
18	}
19
20	if (isset($response->data['featured_media'])) {
21		$image_id  = $response->data['featured_media'];
22
23		$response->data['featured_media'] = \App\format_image_id_for_rest($image_id);
24	}
25
26	// Add featured image to revision
27	if ($post->post_type === 'revision') {
28		$image_id  = get_post_thumbnail_id($post->post_parent);
29
30		$response->data['featured_media'] = \App\format_image_id_for_rest($image_id);
31	}
32
33	return $response;
34}

The help function format_image_id_for_rest we put it back in helpers.php file:

1/**
2 * Format image id for REST
3 */
4function format_image_id_for_rest($id = null) {
5	if (!$id) {
6		return ['id' => $id];
7	}
8
9	$wp_img_src = wp_get_attachment_image_src($id, 'full');
10
11	if ($wp_img_src && is_array($wp_img_src)) {
12		$formatted = [
13			'id'     => $id,
14			'alt'    => get_post_meta($id, '_wp_attachment_image_alt', true),
15			'url'    => $wp_img_src[0],
16			'width'  => $wp_img_src[1],
17			'height' => $wp_img_src[2]
18		];
19
20		return $formatted;
21	}
22
23	return ['id' => $id];
24}

The featured_media property in the standard REST API response will now look like this:

1    "featured_media": {
2        "alt": "Alternatieve tekst",
3        "height": 1500,
4        "id": 926,
5        "url": "https://cms.example.test/2023/06/mijn-afbeelding.jpg",
6        "width": 2000
7    }

From your frontend you can now based on the id make an API call to the Responsive Pics plugin to resize an image:

1https://cms.example.test/wp-json/responsive-pics/v1/image/{featured_media.id}?sizes=xs-12,md-8,xl-6&crop=.65&classes=my-class&lazyload=true&lqip=false

In this example, the API will return a HTML string from a responsive <img> tag that is 12 columns wide, from 768px 8 columns and from 1200px 6 columns. All sizes are cropped to a ratio of 0.65 (height is 65% of width). The img tag gets a css class of my-class and lazyload. Also, will be used instead of src & srcset, data-src & data-srcset, since the layzload parameter is true.

7. Advanced Custom Fields

If, like us, you frequently use Advanced Custom Fields, you can filter the REST API response of each field type from plugin version 5.11. For example, to make all permalinks relative here and to format all image fields to the same format as in step 6, we can use the same helper functions. We also create a separate PHP class Acf for this:

1namespace App\Controllers;
2
3class Acf
4{
5	/**
6	 * Format gallery fields value for REST
7	 */
8	public static function format_gallery_value_for_rest($value_formatted, $post_id, $field, $value, $format)
9	{
10		if (!empty($value_formatted)) {
11			foreach($value_formatted as $index => $image_id) {
12				$image = \App\format_image_id_for_rest($image_id);
13				$value_formatted[$index] = $image;
14			}
15		}
16
17		return $value_formatted;
18	}
19
20	/**
21	 * Format image fields value for REST
22	 */
23	public static function format_image_value_for_rest($value_formatted, $post_id, $field, $value, $format)
24	{
25		$image = \App\format_image_id_for_rest($value_formatted);
26
27		return $image;
28	}
29
30	/**
31	 * Format link fields value for REST
32	 */
33	public static function format_link_value_for_rest($value_formatted, $post_id, $field, $value, $format)
34	{
35		$headless = \App\make_relative_url($value_formatted);
36
37		return $headless;
38	}
39
40	/**
41	 * Format WYSIWYG fields value for REST
42	 */
43	public static function format_wysiwyg_for_rest($value_formatted, $post_id, $field, $value, $format)
44	{
45		$headless = \App\make_relative_url($value_formatted);
46
47		return $headless;
48	}
49}

Add the filters to your functions.php:

1add_filter('acf/rest/format_value_for_rest/type=gallery', ['App\Controllers\Acf', 'format_gallery_value_for_rest'], 10, 5);
2add_filter('acf/rest/format_value_for_rest/type=image',   ['App\Controllers\Acf', 'format_image_value_for_rest'], 10, 5);
3add_filter('acf/rest/format_value_for_rest/type=link',    ['App\Controllers\Acf', 'format_link_value_for_rest'], 10, 5); 
4add_filter('acf/rest/format_value_for_rest/type=wysiwyg', ['App\Controllers\Acf', 'format_wysiwyg_for_rest'], 10, 5);
8. Yoast SEO

To also be able to use the popular Yoast SEO plugin 1-on-1 in a headless setup, we can add the yoast_head of yoast_head_json properties in the REST API responses added by the plugin. However, these often also contain absolute URLs to the CMS.

Fortunately, there are also all kinds of filters for this. In our case we wanted to replace all canonical, open graph and all schema website, webpage & breadcrumb URLs with the URL of the frontend as these kind of URLs cannot be relative. If you are going to use the content in multiple places, it is better to do this 'find and replace' in the frontend itself. We also create a separate PHP class Yoast for this:

1namespace App\Controllers;
2
3class Yoast
4{
5	/**
6	 * Filters the canonical URL.
7	 *
8	 * @param string $canonical The current page's generated canonical URL.
9	 *
10	 * @return string The filtered canonical URL.
11	 */
12	public static function filter_wpseo_canonical_url($canonical)
13	{
14		$headless = \App\make_headless_url(urldecode($canonical));
15
16		return urlencode($headless);
17	}
18
19	/**
20	 * Filter og:url tag and replace admin url with front url
21	 */
22	public static function filter_wpseo_og_url($url)
23	{
24		$headless = \App\make_headless_url(urldecode($url));
25
26		return urlencode($headless);
27	}
28
29	/**
30	 * Filter wpseo_schema data and replace admin url with front url
31	 */
32	public static function filter_wpseo_schema($data)
33	{
34		$headless = \App\make_headless_url($data);
35
36		return $headless;
37	}
38}

Add the filters to your functions.php:

1add_filter('wpseo_canonical',         ['App\Controllers\Yoast', 'filter_wpseo_canonical_url'], 10, 1);
2add_filter('wpseo_opengraph_url',     ['App\Controllers\Yoast', 'filter_wpseo_og_url'], 10, 1);
3add_filter('wpseo_schema_website',    ['App\Controllers\Yoast', 'filter_wpseo_schema'], 10, 1);
4add_filter('wpseo_schema_webpage',    ['App\Controllers\Yoast', 'filter_wpseo_schema'], 10, 1);
5add_filter('wpseo_schema_breadcrumb', ['App\Controllers\Yoast', 'filter_wpseo_schema'], 10, 1);

We place the helpers such as make_headless_url in a helpers.php file:

1/**
2 * Convert absolute backend url's to absolute frontend url's
3 */
4function make_headless_url($data = null)
5{
6	if (!$data) {
7		return;
8	}
9
10	if (isset($_SERVER['WP_FRONT_URL']) && isset($_SERVER['WP_HOME'])) {
11		$back_url  = $_SERVER['WP_HOME'];
12		$front_url = $_SERVER['WP_FRONT_URL'];
13
14		if (is_string($data)) {
15			$pattern  = '~'. preg_quote($_SERVER['WP_HOME']) .'(?!/app)~i';
16			$headless = preg_replace($pattern, $front_url, $data);
17
18			return $headless;
19		}
20
21		if (is_array($data)) {
22			$headless = [];
23			foreach ($data as $key => $value) {
24				$headless[$key] = make_headless_url($value);
25			}
26
27			return $headless;
28		}
29	}
30
31	return $data;
32}

In our case, the WP_FRONT_URL value is filled by means of an environment variable, but you can also use a normal variable for this, for example.

After implementing these filters, your response should look like this:

1{
2    "yoast_head": "<!-- This site is optimized with the Yoast SEO plugin v20.10 - https://yoast.com/wordpress/plugins/seo/ -->\n<title>My Site - Just another WordPress site</title>\n<meta name=\"robots\" content=\"noindex, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" />\n<meta property=\"og:locale\" content=\"nl_NL\" />\n<meta property=\"og:type\" content=\"article\" />\n<meta property=\"og:title\" content=\"My Site - Just another WordPress site\" />\n<meta property=\"og:description\" content=\"Arcu curabitur nam nulla iaculis mauris. Senectus nascetur posuere leo consectetuer pretium. Lacus commodo letius nulla sodales vulputate viverra mattis. Vestibulum senectus mus donec id mattis placerat netus at justo vivamus.\" />\n<meta property=\"og:url\" content=\"https://example.test/\" />\n<meta property=\"og:site_name\" content=\"My Site\" />\n<meta property=\"article:modified_time\" content=\"2023-05-25T10:36:46+00:00\" />\n<meta property=\"og:image\" content=\"https://cms.example.test/app/uploads/2023/06/Share-image.png\" />\n\t<meta property=\"og:image:width\" content=\"1200\" />\n\t<meta property=\"og:image:height\" content=\"628\" />\n\t<meta property=\"og:image:type\" content=\"image/png\" />\n<meta name=\"twitter:card\" content=\"summary_large_image\" />\n<meta name=\"twitter:label1\" content=\"Geschatte leestijd\" />\n\t<meta name=\"twitter:data1\" content=\"1 minuut\" />\n<script type=\"application/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https://schema.org\",\"@graph\":[{\"@type\":\"WebPage\",\"@id\":\"https://example.test/\",\"url\":\"https://example.test/\",\"name\":\"My Site - Just another WordPress site\",\"isPartOf\":{\"@id\":\"https://example.test/#website\"},\"datePublished\":\"2022-02-05T19:24:08+00:00\",\"dateModified\":\"2023-05-25T10:36:46+00:00\",\"inLanguage\":\"nl-NL\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https://example.test/\"]}]},{\"@type\":\"WebSite\",\"@id\":\"https://example.test/#website\",\"url\":\"https://example.test/\",\"name\":\"My Company\",\"description\":\"Just another WordPress site\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https://example.test/?s={search_term_string}\"},\"query-input\":\"required name=search_term_string\"}],\"inLanguage\":\"nl-NL\"}]}</script>\n<!-- / Yoast SEO plugin. -->",
3    "yoast_head_json": {
4        "article_modified_time": "2023-05-25T10:36:46+00:00",
5        "og_description": "Arcu curabitur nam nulla iaculis mauris. Senectus nascetur posuere leo consectetuer pretium. Lacus commodo letius nulla sodales vulputate viverra mattis. Vestibulum senectus mus donec id mattis placerat netus at justo vivamus.",
6        "og_image": [
7            {
8                "height": 628,
9                "type": "image/png",
10                "url": "https://cms.example.test/app/uploads/2023/06/Share-image.png",
11                "width": 1200
12            }
13        ],
14        "og_locale": "nl_NL",
15        "og_site_name": "My Site",
16        "og_title": "My Site - Just another WordPress site",
17        "og_type": "article",
18        "og_url": "https://example.test/",
19        "robots": {
20            "follow": "follow",
21            "index": "noindex",
22            "max-image-preview": "max-image-preview:large",
23            "max-snippet": "max-snippet:-1",
24            "max-video-preview": "max-video-preview:-1"
25        },
26        "schema": {
27            "@context": "https://schema.org",
28            "@graph": [
29                {
30                    "@id": "https://example.test/",
31                    "@type": "WebPage",
32                    "dateModified": "2023-05-25T10:36:46+00:00",
33                    "datePublished": "2022-02-05T19:24:08+00:00",
34                    "inLanguage": "nl-NL",
35                    "isPartOf":
36                    {
37                        "@id": "https://example.test/#website"
38                    },
39                    "name": "My Site - Just another WordPress site",
40                    "potentialAction": [
41                        {
42                            "@type": "ReadAction",
43                            "target":
44                            [
45                                "https://example.test/"
46                            ]
47                        }
48                    ],
49                    "url": "https://example.test/"
50                },
51                {
52                    "@id": "https://example.test/#website",
53                    "@type": "WebSite",
54                    "description": "Just another WordPress site",
55                    "inLanguage": "nl-NL",
56                    "name": "My Company",
57                    "potentialAction": [
58                        {
59                            "@type": "SearchAction",
60                            "query-input": "required name=search_term_string",
61                            "target":
62                            {
63                                "@type": "EntryPoint",
64                                "urlTemplate": "https://example.test/?s={search_term_string}"
65                            }
66                        }
67                    ],
68                    "url": "https://example.test/"
69                }
70            ]
71        },
72        "title": "My Site - Just another WordPress site",
73        "twitter_card": "summary_large_image",
74        "twitter_misc": {
75            "Geschatte leestijd": "1 minuut"
76        }
77    }
78}

To make Yoast's sitemap functionality compatible with a headless setup, we also need to replace the absolute URLs with absolute frontend URLs. Here we add 2 more functions to the Yoast class:

1namespace App\Controllers;
2
3class Yoast
4{
5	/**
6	 * Filter the sub-sitemaps links before the index sitemap is built
7	 */
8	public static function filter_wpseo_sitemap_index_links($links)
9	{
10		$headless = \App\make_headless_url($links);
11
12		return $headless;
13	}
14
15	/**
16	 * Filter sitemap url tags and replace admin url with front url
17	 */
18	public static function filter_wpseo_sitemap_url($output, $url)
19	{
20		$headless = \App\make_headless_url($output);
21
22		return $headless;
23	}
24}

And 2 filters to you functions.php:

1add_filter('wpseo_sitemap_index_links', ['App\Controllers\Yoast', 'filter_wpseo_sitemap_index_links'], 10, 1);
2add_filter('wpseo_sitemap_url',         ['App\Controllers\Yoast', 'filter_wpseo_sitemap_url'], 10, 2);

Your XML sitemap should now look like this:

1<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="//cms.example.test/main-sitemap.xsl"?>
2<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3	<sitemap>
4		<loc>https://example.test/page-sitemap.xml</loc>
5		<lastmod>2023-06-26T14:24:38+00:00</lastmod>
6	</sitemap>
7	<sitemap>
8		<loc>https://example.test/post-sitemap.xml</loc>
9		<lastmod>2023-06-26T13:51:38+00:00</lastmod>
10	</sitemap>
11</sitemapindex>
12<!-- XML Sitemap generated by Yoast SEO -->
In conclusion

After applying the above code snippets, WordPress is ready to be used as a full-fledged headless CMS. There will always be edge cases with other popular plugins, but in our experience most problems can be solved with plugin-specific hooks. Have fun!

Discover what we can do for you

We are happy to explain more about the possibilities of web development. Together with you, we look at how you can best use this for your project.

Contact us