Consuming the REST API

Calling the REST API from JavaScript with apiFetch or fetch, passing the wp_rest nonce, and what CORS does and does not allow.

SyntaxapiFetch( { path: '/wp/v2/posts?per_page=5' } ).then( ( posts ) => { … } )

From JavaScript running on the WordPress site itself, use apiFetch. From anywhere else, use fetch and supply authentication yourself.

apiFetch

@wordpress/api-fetch is a wrapper around window.fetch. Enqueue the wp-api-fetch handle as a dependency and core attaches inline scripts to it that register a root-URL middleware built from get_rest_url() and a nonce middleware built from wp_create_nonce( 'wp_rest' ) — so on the site's own pages the nonce is already handled. import apiFetch from '@wordpress/api-fetch' in a build step and the global wp.apiFetch are the same function.

Its options are shorthand for fetch's: path stands in for a full url, and data stands in for body: JSON.stringify( … ) plus the JSON content type. It returns a Promise resolving to parsed JSON; pass parse: false to get the raw Response when you need headers such as X-WP-Total.

Plain fetch and the nonce

Without apiFetch, pass the root URL and a wp_rest nonce into the page with wp_localize_script(), send the nonce as the X-WP-Nonce header, and set credentials: 'same-origin' so the auth cookie travels.

CORS

Core's rest_send_cors_headers() (WordPress 4.4) echoes the request's Origin back and sets Access-Control-Allow-Credentials: true with Vary: Origin. Browsers still default cookies to SameSite=Lax, so the WordPress auth cookie is not sent cross-site — a separate front end must authenticate another way.

Example

Example · php
<?php
/**
 * The PHP half of consuming the API from your own JavaScript.
 */
add_action( 'wp_enqueue_scripts', 'acme_enqueue_frontend' );

function acme_enqueue_frontend() {
    // Option A -- apiFetch. Core adds inline scripts to the 'wp-api-fetch'
    // handle that install a root-URL middleware and a wp_rest nonce middleware,
    // so you do NOT need to localize a nonce for it.
    wp_enqueue_script(
        'acme-app',
        plugins_url( 'build/app.js', __FILE__ ),
        array( 'wp-api-fetch' ),
        '1.0.0',
        true
    );

    // Option B -- plain fetch. Then you must hand over the root URL and nonce
    // yourself. The nonce action is always the literal string 'wp_rest'.
    wp_enqueue_script(
        'acme-legacy',
        plugins_url( 'js/legacy.js', __FILE__ ),
        array(),
        '1.0.0',
        true
    );

    wp_localize_script(
        'acme-legacy',
        'acmeSettings', // becomes window.acmeSettings
        array(
            'root'    => esc_url_raw( rest_url() ), // permalink-aware; never hard-code /wp-json/
            'nonce'   => wp_create_nonce( 'wp_rest' ),
            'postId'  => get_queried_object_id(),
        )
    );
}

When to use it

  • Loading more posts into a front-end list without a page reload.
  • Saving a custom meta field from a block's inspector controls.
  • Building a paged archive that shows a total count from X-WP-Total.
  • Wiring a separate SPA to a WordPress backend on another domain.

More examples

The same request in apiFetch shorthand and fetch longhand

Worth reading side by side once, because apiFetch hides four separate things: the root URL, the JSON encoding, the content type and the nonce. When a call fails, knowing which of the four is missing is the whole debugging job — and it explains why the same code pasted into a page that did not enqueue wp-api-fetch returns 401.

Example · javascript
import apiFetch from '@wordpress/api-fetch';
// Identical to the global that the wp-api-fetch handle exposes:
//   const apiFetch = wp.apiFetch;

// Shorthand. `path` is appended to the REST root by the root-URL middleware,
// `data` is serialised to a JSON body with the right Content-Type, and the
// nonce middleware adds X-WP-Nonce. Resolves to parsed JSON.
apiFetch( {
	path: '/wp/v2/posts/42',
	method: 'POST',
	data: { title: 'Updated title' },
} ).then( ( post ) => console.log( post.title.rendered ) );

// Longhand -- exactly what the call above expands to.
fetch( `${ acmeSettings.root }wp/v2/posts/42`, {
	method: 'POST',
	credentials: 'same-origin', // without this the auth cookie is not sent
	headers: {
		'Content-Type': 'application/json',
		'X-WP-Nonce': acmeSettings.nonce,
	},
	body: JSON.stringify( { title: 'Updated title' } ),
} )
	.then( ( response ) => {
		if ( ! response.ok ) {
			// The body still carries { code, message, data: { status } }.
			return response.json().then( ( err ) => Promise.reject( err ) );
		}
		return response.json();
	} )
	.then( ( post ) => console.log( post.title.rendered ) );

Reading pagination headers, which need parse: false

Any infinite-scroll or paged list needs this. It is also the standard first surprise with apiFetch: the totals are not in the JSON body, and the default parse: true throws the headers away before you can reach them.

Example · javascript
import apiFetch from '@wordpress/api-fetch';

async function loadPage( page = 1 ) {
	// parse: false makes the Promise resolve to the raw Response, so the
	// headers survive. The default (parse: true) gives you only the body.
	const response = await apiFetch( {
		path: `/wp/v2/posts?per_page=10&page=${ page }&_fields=id,title,link`,
		parse: false,
	} );

	// These two are in core's CORS expose list, so they are readable even
	// cross-origin. Most other custom headers are not.
	const totalPages = parseInt( response.headers.get( 'X-WP-TotalPages' ), 10 );
	const total = parseInt( response.headers.get( 'X-WP-Total' ), 10 );

	const posts = await response.json();

	return { posts, total, totalPages, hasMore: page < totalPages };
}

// Requesting a page beyond the last one returns 400 rest_post_invalid_page_number,
// not an empty array -- so guard on hasMore rather than on posts.length.

Fetching from a separate front end, cross-origin

The realistic headless setup. CORS is not the hard part — core already sends permissive headers for reads — the hard part is that cookie authentication does not cross origins, which is why credentials belong on a server you control and never in the bundle you ship to browsers.

Example · javascript
// A Next.js / SvelteKit / plain SPA on https://app.example.com talking to
// WordPress on https://cms.example.com.

// Public reads work with no configuration. Core echoes the Origin back and
// sets Vary: Origin, so a plain GET is fine.
const posts = await fetch(
	'https://cms.example.com/wp-json/wp/v2/posts?per_page=10'
).then( ( r ) => r.json() );

// Authenticated calls are the problem. The WordPress auth cookie is set
// without SameSite=None, so browsers will not send it cross-site -- and a
// wp_rest nonce from the CMS is useless without the cookie it is bound to.
//
// Do NOT put an application password in browser JavaScript: it is a full
// credential for that user and is visible to anyone opening devtools.
// Proxy the write through your own server instead:
await fetch( '/api/publish', {          // same-origin route on app.example.com
	method: 'POST',
	headers: { 'Content-Type': 'application/json' },
	body: JSON.stringify( { title: 'From the app' } ),
} );
// ...and on that server, call WordPress with Basic auth and an application
// password held in an environment variable.

Discussion

  • Be the first to comment on this lesson.