Web development
28 min leestijd

WordPress als headless CMS

Toine Kamps

Web developer

WordPress is nog steeds het favoriete Content Management System (CMS) voor het bouwen van websites. Het marktaandeel van WordPress binnen vergelijkbare CMS-systemen was in juni 2023 zelfs 63.2%. Met een absoluut gebruikerspercentage van 43.1% van alle websites. Dat zijn enorme aantallen.

Waarom is WordPress nog steeds zó populair?

Het succes zit hem in het gebruikersgemak waarmee iedereen inhoud zoals teksten, afbeeldingen en video’s kan toevoegen, bewerken en publiceren. Maar dat is anno 2023 niet meer uniek. Er zijn inmiddels talloze CMS-systemen met misschien wel nóg betere gebruikerservaringen. Het allergrootste voordeel van WordPress is dat het de afgelopen jaren (de eerste versie kwam uit in 2003) een enorm ecosysteem van plugins, thema's en integraties heeft opgebouwd. Met daaromheen een enorme gebruikerscommunity. Voor elke gewenste extra functionaliteit is wel een plugin of andere oplossing te vinden.

Dit was precies de reden waarom wij met Clarify de mogelijkheid wilden onderzoeken om WordPress ook als een headless CMS aan te kunnen gaan bieden.

Wat is een headless CMS eigenlijk?

Een headless CMS is een type Content Management System waarbij het beheer van de inhoud losgekoppeld is van de visuele presentatie ervan. In plaats van dat je de inhoud via hetzelfde systeem presenteert via templates met voorgedefinieerde layouts en stijlen, wordt de inhoud beschikbaar gesteld via een API (Application Programming Interface). Die vervolgens in meerdere applicaties geconsumeerd kan worden als databron.

Hierdoor hoef je het headless CMS alleen te gebruiken om je inhoud te beheren en op te slaan in een centrale omgeving, terwijl je het via allerlei verschillende manieren presenteert aan de eindgebruiker. Bijvoorbeeld in een website of een aparte mobiele app. Dit biedt een enorme flexibiliteit in de visuele presentatie én prestaties. Zo kun je de inhoud bijvoorbeeld in een website visualiseren met een front-end framework naar keuze, bijvoorbeeld React of Angular. En tegelijkertijd ook in een native iOS app met een totaal andere layout.

Voordelen van een headless CMS
Betere prestaties

Doordat je niet meer gebonden bent aan het framework van WordPress zelf om je frontend te visualiseren, kun je gebruikmaken van alle andere moderne frameworks en tools die meer gericht zijn op prestaties en gebruikerservaring. Ons favoriete framework hiervoor is Next.js.

Betere schaalbaarheid

De architectuur van jouw losgekoppelde frontend kun je zelf helemaal bepalen en inrichten. Door bijvoorbeeld gebruik te maken van Docker kun je je applicatie heel makkelijk opschalen en zelfs verspreiden over meerdere servers. Dit zou met WordPress zelf veel lastiger zijn.

Betere beveiliging

Mede door het succes van WordPress is het ook een gewild doelwit voor hackers en DDoS aanvallen. Aangezien het headless CMS helemaal is afgeschermd voor bezoekers, biedt het veel meer bescherming tegen dit soort aanvallen. Ook heb je veel meer opties om je frontend zelf verder te beveiligen door gebruik te maken van diensten zoals Cloudflare.

Verschillende kanalen

Zoals hierboven al aangegeven kun je je content op verschillende kanalen tegelijkertijd gebruiken. Wat uiteraard veel efficiënter is dan op meerdere plekken content te moeten beheren.

Nadelen van een headless CMS
Meer werk en daardoor duurder

Aangezien je naast het headless CMS ook nog een aparte frontend dient te ontwikkelen, ben je meer tijd kwijt. Dit maakt een headless CMS setup ook wat duurder dan een traditionelere WordPress setup.

Inconsistente gebruikerservaring

Door je content op losgekoppelde en soms zelfs meerdere plekken te gebruiken, kan dit een inconsistente gebruikerservaring opleveren.

Geen visuele editor

Aangezien de visuele presentatie van je content afhangt van waar deze getoond wordt, heeft de nieuwe Gutenberg editor van Wordpress die in 2018 werd gelanceerd, weinig meerwaarde. Ook de traditionele WYSIWYG editor kan inconsistentie opleveren in de daadwerkelijke presentatie van de content.

Wordpress inrichten als headless CMS

WordPress is ooit ontwikkeld als een blogging tool waarmee je snel en out-of-the-box een blog website kon maken. Inmiddels is het uitgegroeid tot zoveel meer dan dat. Maar je merkt hier en daar dat het in de kern nog steeds met die mindset is ontwikkeld. In 2003 bestond de term headless CMS nog niet. En ook al heeft WordPress inmiddels een goed werkende REST API waarmee je in principe het CMS headless kunt gebruiken. Je merkt op veel plekken in de kernfunctionaliteiten van het systeem dat het hier eigenlijk nooit voor ontwikkeld is.

Maar is het dan wel geschikt om het headless te gebruiken? Jazeker! Met een aantal handige code snippets willen we je hier op weg helpen om zo het CMS én de REST API geschikter te maken voor een headless setup.

1. Gutenberg uitzetten

Een van de eerste WordPress-functionaliteiten die we willen uitschakelen in een headless-setup, is de Gutenberg editor. Aangezien deze echt ontwikkeld is als visuele content editor, hebben we hier niks aan in een headless-systeem. Aangezien de content er overal anders uit kan zien. Plus, het neemt extra tijd in beslag tijdens het ontwikkelen van de back- én frontend.

Je kunt de Gutenberg editor eenvoudig uitschakelen met één regel code:

1add_filter('use_block_editor_for_post', '__return_false', 10);
2. Relatieve URL's

Waar WordPress nog niet zo goed op ingericht is, zijn de permalinks. Elke pagina, bericht of post type krijgt zijn eigen permalinkstructuur, maar deze is altijd absoluut en gebaseerd op het Site adres (URL) in WordPress. Wij kiezen er als Clarify meestal voor om een apart subdomein en aparte server te gebruiken voor het CMS.

Stel dat je een website wilt maken en voor de frontend het domein example.test gaat gebruiken, dan is het een logische keuze om voor het headless CMS bijvoorbeeld cms.example.test of admin.example.test te gaan gebruiken. Dit domein wordt dan vervolgens ook als Site adres (URL) in WordPress gebruikt.

Een typische standaard REST API response voor een standaard pagina met behulp van het endpoint https://cms.example.test/wp-json/wp/v2/pages/<id> zal er daardoor zo uit gaan zien:

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}

Om alle link properties en eventuele permalinks in de content in de REST API responses relatief te maken, kunnen we gebruikmaken van de rest_prepare_{$this->post_type} filter. Voor al dit soort REST API tweaks maken wij een aparte PHP klasse RestAPI:

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}

Voeg de filters toe aan je 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);

Alle hulpfuncties zoals make_relative_url plaatsen we in een helpers.php bestand:

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 ons geval wordt de WP_HOME waarde gevuld door middel van een environment variabel, maar je kunt hier ook de get_home_url() functie voor gebruiken. Alle eventuele links naar uploads, plugins en thema's (die in ons geval in een aparte folder app staan) wil je juist niet relatief maken. Hier kun je een PHP regex patroon zoals '~'. preg_quote($_SERVER['WP_HOME']) .'(?!/app)~i' voor gebruiken.

Na het implementeren van deze filters zou je response er zo uit moeten zien:

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}

Alle absolute permalinks in de link en content properties zijn nu relatief geworden.

3. Gedeelde data

Een veel voorkomend patroon voor een headless CMS is dat je sommige data op elke pagina nodig hebt. Denk hierbij aan de WordPress site titel en slogan, menu's, footer content of bepaalde CMS instellingen. Hier hebben wij een custom example/v1/common endpoint voor gemaakt. In dit endpoint halen we in ons voorbeeld alle WordPress navigatie menu's op, alle Advanced Custom Fields opties en alle nuttige WordPress instellingen:

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}

Roep de functie aan vanuit je functions.php:

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

Om alle Wordpress menu's op te halen en op te maken voor een headless setup, hebben we een aparte PHP-klasse Menus gemaakt:

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}

Deze common data is nu beschikbaar via het custom endpoint:

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

en zal, afhankelijk van het door jou aantal gedefiniëerde menu's en ACF-opties er ongeveer zo uitzien:

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. Voorpagina

Het eerste wat je vaak in je frontend wilt doen, is het eerste entry point ofwel de voorpagina definiëren. In een klassieke WordPress setup is dit gemakkelijk door een template-bestand te maken die ofwel front-page.php heet voor een statische pagina, ofwel home.php voor je laatste berichten.

Helaas is hier geen standaard endpoint voor in de REST API zonder al de id te weten van de door jou ingestelde voorpagina. Ook hier maken wij een custom example/v1/front-page endpoint voor die checkt wat er ingesteld staat als de voorpagina en daar de bijbehorende response voor terugstuurt. Dit endpoint definiëren we in een nieuwe PHP klasse 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}

Roep de functie aan vanuit je functions.php:

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

Deze front-page data is nu beschikbaar via het custom endpoint:

1https://cms.example.test/wp-json/example/v1/front-page
5. Pagina op basis van zijn pad

Indien je frontend uit meerdere pagina's zal bestaan, dien je zelf de route logica te implementeren. In Next.js bijvoorbeeld kan dat gemakkelijk met dynamic catch-all routes. In een klassieke WordPress setup kun je eenvoudig met een page.php template bestand de juiste pagina tonen. Echter in een headless setup moet je de gewenste pagina zien te identificeren op basis van een parameter die je meestuurt naar een REST API call. Het meest logische is om dat op basis van de binnenkomende URL in de frontend te doen.

Als je bijvoorbeeld in de frontend op de pagina example.test/over-ons/ binnenkomt, zal de pagina opgehaald moeten worden op basis van de slug over-ons. Voor pagina's met een ingestelde hoofdpagina in WordPress, zal dit een pad worden, bv. voor example.test/over-ons/team wordt dit over-ons/team.

Helaas heeft WordPress ook hier geen standaard REST API endpoint voor zonder van te voren al de pagina id te weten, dus voegen we ook hier ons eigen custom endpoint example/v1/page/(?P[a-zA-Z0-9-/_]+) voor toe aan de Pages klasse:

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}

Het (?P[a-zA-Z0-9-/_]+) gedeelte definieert de page_path variabele en geeft aan dat het mag bestaan uit een slug of pad. In de frontend kun je nu elke gewenste pagina ophalen door de volgende calls uit te voeren:

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

Het beheren van afbeeldingen in een headless setup is ook een lastig onderwerp, idealiter wil je de WordPress media library blijven gebruiken voor al je verschillende frontend kanalen, zo blijft al je media op één plek. Eventueel kun je vanuit WordPress nog al je media offloaden naar een S3 bucket met behulp van een plugin zoals bijvoorbeeld WP Offload Media.

Het nadeel van de meeste content management systemen is dat ze alle geüploade afbeeldingen schalen en eventueel bijsnijden naar vooraf gedefinieerde maten en verhoudingen, zo ook in WordPress. Om individuele afbeeldingen on-the-fly te kunnen schalen en bijsnijden hebben we met Clarify hiervoor de plugin Responsive Pics ontwikkeld. We hebben hierover in 2021 een uitgebreid artikel Resize images with Responsive Pics geschreven.

Aangezien de frontend bepaalt hoe groot een afbeelding in een bepaalde layout moet verschijnen, wil je vanuit de frontend een verzoek doen voor een bepaalde maat. In onze plugin hebben we ook een REST API toegevoegd die hierin voorziet. Documentatie hierover kun je hier vinden. Deze API is ook gebaseerd op de unieke WordPress id van een media item. Het is dus belangrijk dat de REST API responses van WordPress op zijn minst de image id bevatten, maar in sommige gevallen wil je ook de url en maten hebben van de originele afbeelding (denk hierbij bijvoorbeeld aan aspect ratio boxes).

Hiervoor voegen we ook een aantal filters toe in WordPress. In stap 2 hadden we al de rest_prepare_headless_data functie gedefinieerd. Hier kunnen we aan toevoegen:

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}

De hulpfunctie format_image_id_for_rest plaatsen we weer in het helpers.php bestand:

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}

De featured_media property in de standaard REST API response zal er nu zo uit zien:

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    }

Vanuit je frontend kun je nu op basis van de id een API call maken naar de Responsive Pics plugin om een afbeelding te resizen:

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 dit voorbeeld zal de API een HTML string terugsturen van een responsive <img> tag die 12 kolommen breed is, vanaf 768px 8 kolommen en vanaf 1200px 6 kolommen. Alle formaten zijn bijgesneden naar een ratio van 0,65 (hoogte is 65% van de breedte). De img tag krijgt een css klasse van my-class én lazyload. Ook zullen in plaats van src & srcset, data-src & data-srcset gebruikt worden, aangezien de layzload parameter true is.

7. Advanced Custom Fields

Indien je net als wij veelvuldig gebruikmaakt van Advanced Custom Fields, kun je vanaf plugin versie 5.11 de REST API response van elk veldtype filteren. Om bijvoorbeeld ook hier alle permalinks relatief te maken en alle afbeeldingsvelden te formatteren naar hetzelfde formaat als in stap 6, kunnen we dezelfde helperfuncties gebruiken. Ook hiervoor maken wij een aparte PHP klasse Acf aan:

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}

Voeg de filters toe aan je 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

Om ook de populaire Yoast SEO plugin 1-op-1 te kunnen gebruiken in een headless setup, kunnen we de yoast_head of yoast_head_json properties gebruiken in de REST API responses die door de plugin zijn toegevoegd. Echter bevatten deze vaak ook absolute URL's naar het CMS.

Gelukkig bestaan hier ook allerlei filters voor. In ons geval wilden we alle canonical, open graph en alle schema website, webpage & breadcrumb URL's vervangen met de URL van de frontend aangezien dit soort URL's niet relatief kunnen zijn. Als je de content op meerdere plekken gaat gebruiken kun je deze 'find and replace' dus beter in de frontend zelf doen. Ook hiervoor maken wij een aparte PHP klasse Yoast aan:

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}

Voeg de filters toe aan je 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);

De hulpfuncties zoals make_headless_url plaatsen we in een helpers.php bestand:

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 ons geval wordt de WP_FRONT_URL waarde gevuld door middel van een environment variabel, maar je kunt hier bv. ook een normale variabele voor gebruiken.

Na het implementeren van deze filters zou je response er zo uit moeten zien:

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}

Om ook de sitemap functionaliteit van Yoast compatibel te maken met een headless setup, dienen we ook hier de absolute URL's te vervangen voor absolute frontend URL's. Hier voegen we nog 2 functies voor toe aan de Yoast klasse:

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}

En 2 filters aan je 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);

Je XML sitemap zou er nu zo uit moeten zien:

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 -->
Tot slot

Na het toepassen van bovenstaande code snippets is WordPress klaar om gebruikt te worden als volwaardig headless CMS. Er zullen altijd wel edge cases opduiken met andere populaire plugins, maar onze ervaring is dat de meeste problemen wel op te lossen zijn met plugin-specifieke hooks. Have fun!

Ontdek wat wij voor jou kunnen betekenen

Wij leggen graag meer uit over de mogelijkheden van web development. Samen met jou kijken we hoe je dit het beste voor jouw project in kunt zetten.

Neem contact op