Headless API
Role
ReactWP can be consumed by an external frontend without replacing the integrated WordPress + React mode.
The headless API is a public contract layered over the same runtime that powers the theme shell:
- WordPress still resolves content, menus, settings, SEO, and ACF data
- external frontends receive normalized JSON
- authenticated requests still run inside WordPress, so callbacks can use
wp_get_current_user()
Endpoints
All endpoints use the reactwp/v1 REST namespace.
| Endpoint | Method | Purpose |
|---|---|---|
/wp-json/reactwp/v1/bootstrap | GET | Public first-load payload for external apps |
/wp-json/reactwp/v1/route?view=/about/ | GET | Public route payload. The view parameter is required. |
/wp-json/reactwp/v1/navigation | GET | All normalized menus |
/wp-json/reactwp/v1/navigation?location=primary | GET | One normalized menu location |
/wp-json/reactwp/v1/settings | GET | Public settings only |
/wp-json/reactwp/v1/sitemap | GET | Public route index |
/wp-json/reactwp/v1/preview?postId=42 | GET or POST | Signed preview payload; send the token in a request header |
/wp-json/reactwp/v1/auth/me | GET | Current authenticated user |
/wp-json/reactwp/v1/auth/login | POST | Cookie login for headless frontends |
/wp-json/reactwp/v1/auth/logout | POST | Cookie logout |
Endpoint Parameters and Statuses
| Endpoint | Accepted input | Exact behavior |
|---|---|---|
bootstrap | optional view, optional lang | view must be a safe local path. Without it, ReactWP resolves the WordPress home path. |
route | required view | view must be a safe local path. The response uses HTTP 404 when the normalized route is a 404, otherwise 200. This endpoint does not switch language from a separate lang parameter; pass the localized path in view. |
navigation | optional location, optional lang | location is normalized with sanitize_key() and limited to 100 bytes. Omitting it returns every registered location. |
settings | optional lang | Returns only values contributed through rwp_headless_public_settings. |
sitemap | optional lang | Queries public, published, non-password-protected content, excludes attachments, and orders by modification date descending. |
preview | GET or POST; postId or id; optional lang | Requires a valid token for the resolved post ID. Trashed or missing posts return 404; a successful payload includes preview: true. |
auth/me | no body | Returns the current cookie-authenticated identity and a REST nonce, or authenticated: false. |
auth/login | JSON username, password, optional remember | JSON is required by default. Username and password are limited to 254 and 4096 bytes. Errors deliberately do not identify which credential failed. |
auth/logout | no body | A logged-in user must send a valid X-WP-Nonce. An anonymous request may log out without a nonce. |
A safe view is at most 4096 bytes, begins with one /, has no scheme, host, credentials, fragment, backslash, control character, malformed percent escape, or protocol-relative // prefix. Query strings are allowed and normalized by the route resolver.
Public Contract
Headless responses include:
apiVersiongeneratedAt- endpoint-specific payload data
The public route shape includes:
idtypetemplatestatuslangtitlepageNamepathsearchqueryurlseomediaGroupsdataheadis404linksrender
The current public contract version is 1.4. Rendering metadata is useful to integrated tooling and optional to external frontends, which still own their own rendering strategy.
The public bootstrap exposes both system.headless and system.endpoints. They are two names for the same endpoint collection retained for compatibility: headless uses keys such as bootstrapEndpoint, while endpoints uses shorter keys such as bootstrap. It also exposes system.routeEndpoint for the integrated router. Public bootstrap never exposes currentUser or a REST nonce.
PublicPayload enforces the following response bounds before external data leaves WordPress:
- nested values stop after depth 20
- an array contributes at most 10,000 entries
- an arbitrary string larger than 2 MiB is discarded
route.headaccepts at most 100 strings of at most 65,536 bytes each- navigation accepts at most 100 location keys, 500 items per sibling list, and 10 nested child levels
- a template name must match
[A-Za-z][A-Za-z0-9_.-]{0,127}or falls back toDefault/NotFound - route IDs are integers,
user_<id>,term_<id>, ornull
WordPress posts, terms, users, and attachments embedded in arbitrary route/settings data are converted to bounded public references. Non-public objects, arbitrary PHP objects, non-finite floats, and oversized values become null; this public projection is not a promise to serialize arbitrary plugin objects.
The public bootstrap does not include currentUser. This keeps its payload cacheable and prevents a shared response from mixing public content with one WordPress session. Use the no-store /auth/me endpoint whenever the external frontend needs identity, roles, capabilities, or a REST nonce.
Public bootstrap and route resolution return a 404 payload for drafts, private posts, scheduled posts, trashed posts, password-protected posts, and post types that WordPress does not consider publicly viewable. A valid signed preview token is the explicit exception for unpublished content.
Use this page as the source of truth when wiring a Next, Astro, Remix, or Vite frontend.
Do not consume the inline #reactwp-bootstrap payload from an external application. That is the integrated-theme contract and can contain project-specific internal values. The public endpoints pass through PublicPayload and add the stable response metadata intended for external consumers.
Current Language
Public bootstrap responses expose site.language and site.locale. Every normalized route exposes route.lang.
- use
route.langas the current route language - use
site.languageas the bootstrap fallback - use
site.localefor the complete WordPress locale, such asfr_CA
const language = payload.route.lang || payload.site.language || 'en';
The bootstrap, navigation, settings, sitemap, and preview endpoints accept a sanitized lang parameter for multilingual integrations. ReactWP switches Polylang when available and fires the WPML wpml_switch_language action before constructing those responses:
GET /wp-json/reactwp/v1/bootstrap?lang=fr&view=/fr/a-propos/
GET /wp-json/reactwp/v1/navigation?lang=fr&location=primary
The route endpoint resolves the localized permalink supplied in its required view parameter and returns the resolved language in route.lang.
CORS Allowlist
ReactWP does not use * for headless authentication.
Allowed origins come from:
- the WordPress site URL
- the WordPress home URL
- Site settings > Headless API > Allowed Headless Origins
- the
rwp_headless_allowed_originsfilter
At most 100 candidate origins are considered. Each must normalize to an exact http://host[:port] or https://host[:port] origin with no credentials; * is rejected. ReactWP reflects only an exact allowed origin and sends:
Access-Control-Allow-Methods: OPTIONS, GET, POST, PUT, PATCH, DELETEAccess-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce, X-ReactWP-Preview-TokenAccess-Control-Allow-Credentials: trueVary: Origin
Example:
add_filter('rwp_headless_allowed_origins', function($origins){
$origins[] = 'https://app.example.com';
$origins[] = 'http://localhost:3000';
return $origins;
});
For authenticated browser requests, the frontend origin must be allowlisted and should use HTTPS. Localhost origins are allowed for local development.
Login Flow
External browser frontends can create a WordPress session with:
const response = await fetch('https://cms.example.com/wp-json/reactwp/v1/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: 'editor@example.com',
password: 'password',
remember: true
})
});
const { currentUser } = await response.json();
When login succeeds, ReactWP returns:
- the current user payload
- a
restNonceinsidecurrentUser - WordPress auth cookies set by the CMS domain
Subsequent authenticated requests should include both the cookies and nonce:
await fetch('https://cms.example.com/wp-json/my-app/v1/private-data', {
credentials: 'include',
headers: {
'X-WP-Nonce': currentUser.restNonce
}
});
Inside the WordPress REST callback, normal WordPress user APIs work:
register_rest_route('my-app/v1', '/private-data', [
'methods' => 'GET',
'permission_callback' => function(){
return is_user_logged_in();
},
'callback' => function(){
$user = wp_get_current_user();
return [
'userId' => $user->ID,
'displayName' => $user->display_name,
];
},
]);
The route must also be allowed through ReactWP's REST allowlist if it is used outside the admin REST surface:
add_filter('rwp_allowed_rest_routes', function($routes){
$routes[] = '/my-app/v1/private-data';
return $routes;
});
Logout Flow
Logout requires the REST nonce when a user is logged in:
await fetch('https://cms.example.com/wp-json/reactwp/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
'X-WP-Nonce': currentUser.restNonce
}
});
Security Notes
ReactWP's headless auth is designed for browser-to-CMS requests.
Important defaults:
- login responses never reveal whether the username or password was wrong
- repeated failed logins are rate-limited
- authenticated origins are allowlisted
- preview access uses signed, expiring tokens
- auth and preview responses send no-store cache headers
- public settings are opt-in through
rwp_headless_public_settings - user capabilities are opt-in through
rwp_headless_user_capabilities
Public endpoints are limited per validated client address to 240 requests per 60-second bucket by default. rwp_headless_public_rate_limit is clamped to 0..100000 (0 disables it) and rwp_headless_public_rate_window to 10..3600 seconds. Logged-in users with manage_options bypass this public limit. ReactWP uses REMOTE_ADDR unless rwp_headless_client_ip is deliberately changed; it does not trust forwarding headers automatically.
Failed logins are bounded independently: five failures for one address + normalized username, or 25 failures for the address, lock attempts for 600 seconds. A successful login clears the address + username counter, while the wider address counter remains until expiry.
For production cross-origin cookie auth, serve the CMS over HTTPS and make sure your cookie policy supports the deployment topology. Same-site subdomain setups are usually simpler than unrelated domains.
ReactWP's global REST gate still applies around project endpoints. Public ReactWP endpoints are allowlisted by the runtime. A custom external endpoint must be allowed through rwp_allowed_rest_routes and must keep an appropriate permission_callback of its own.
Preview Tokens
Generate preview tokens server-side:
$token = rwp::preview_token($post_id, 600);
Or use the runtime class directly:
$token = ReactWP\Runtime\PreviewToken::create($post_id, 600);
Request the preview with the token in X-ReactWP-Preview-Token:
const response = await fetch(
'https://cms.example.com/wp-json/reactwp/v1/preview?postId=42',
{
headers: {
'X-ReactWP-Preview-Token': token
}
}
);
Authorization: Bearer <token> is also supported. Query-string preview tokens are disabled by default because URLs can be retained in logs, browser history, and analytics; rwp_headless_allow_preview_query_token is available only for a reviewed legacy integration.
Tokens are signed with WordPress salts, scoped to one post ID, and expire after the configured TTL.
The default token lifetime is 600 seconds, the maximum is 3600 seconds, and a presented token may not exceed 2048 bytes.
Public Settings
The settings endpoint exposes nothing by default.
Initial response:
{
"apiVersion": "1.4",
"generatedAt": "...",
"settings": {}
}
Add project-specific public settings with:
add_filter('rwp_headless_public_settings', function($settings){
$settings['featureFlags'] = [
'accountArea' => true,
];
return $settings;
});
Do not expose secrets, private option values, API keys, or unfiltered admin configuration through this filter.
Current User Payload
By default, auth/me returns a minimal current-user payload.
An anonymous payload is exactly { "authenticated": false }. An authenticated payload contains authenticated, numeric id, slug, displayName, email, roles, the filtered capabilities map, and restNonce. rwp_headless_current_user_payload may replace the complete authenticated payload after those defaults are assembled.
To expose a small capability map:
add_filter('rwp_headless_user_capabilities', function($capabilities, $user){
return [
'readPrivateArea' => user_can($user, 'read'),
'editPosts' => user_can($user, 'edit_posts'),
];
}, 10, 2);
Keep this payload small and intentional. Do not send the full WordPress capabilities array to the browser unless the project truly requires it.
Sitemap Customization
The sitemap query defaults to 500 items and its query limit is clamped to 1..1000. It queries every public post type except attachment, then rejects anything that no longer passes ReactWP's public-object check. After rwp_headless_sitemap_items runs, the public response layer accepts at most 5000 items; this higher projection cap allows a project filter to replace or extend the queried list deliberately.
Customize it with:
rwp_headless_sitemap_post_typesrwp_headless_sitemap_limitrwp_headless_sitemap_items
See Hooks and Filters.
Caching Responses
Auth, login, logout, and preview payloads use no-store behavior. Do not cache those responses at a CDN.
Public route, bootstrap, navigation, settings, and sitemap responses can be cached according to project infrastructure, but a project must define an invalidation strategy when WordPress content changes. The integrated ReactWP browser generation does not automatically purge an external frontend's CDN.