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.
apiFetch( { 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
<?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.
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.
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.
// 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