WordPress REST API Development Complete Guide
Since WordPress 4.7, REST API is built-in. You don't need any plugins to let WordPress talk to any frontend framework. I spent 3 weeks converting a WordPress site to a React frontend + WordPress backend decoupled architecture, and hit 5 real pitfalls before I figured out how to use this properly.
When to Use REST API vs WP-CLI
WP-CLI is for server-side batch operations (batch plugin updates, database replaces). REST API is for:
- Decoupled sites (React/Vue frontends)
- Mobile app backends
- Third-party system integrations
- Authenticated remote operations
If your need is just "operate WordPress via command line," WP-CLI is enough—you don't need to go through REST API.
Register a Custom Endpoint
In functions.php or a standalone plugin:
add_action('rest_api_init', function () {
register_rest_route('tech/v1', '/posts-stats', [
'methods' => 'GET',
'callback' => function ($request) {
$posts = wp_count_posts();
return [
'publish' => $posts->publish,
'draft' => $posts->draft,
'total' => $posts->total,
'last_updated' => get_lastpostmodified('GMT'),
];
},
'permission_callback' => '__return_true',
]);
});
This creates endpoint: /wp-json/tech/v1/posts-stats
**Verify**: GET https://yoursite.com/wp-json/tech/v1/posts-stats
Endpoints with Parameters
register_rest_route('tech/v1', '/author/(?P\d+)', [
'methods' => 'GET',
'callback' => function ($request) {
$author_id = (int) $request['id'];
$user = get_userdata($author_id);
if (!$user) return new WP_Error('not_found', 'User not found', ['status' => 404]);
return [
'id' => $user->ID,
'name' => $user->display_name,
'posts' => count_user_posts($author_id),
'role' => $user->roles[0] ?? 'subscriber',
];
},
'permission_callback' => '__return_true',
]);
Access: /wp-json/tech/v1/author/5
Permission Control: Don't Go Bare
The example above uses permission_callback => '__return_true', meaning anyone can access. Production environments must add verification:
'permission_callback' => function ($request) {
// Only allow logged-in users
return is_user_logged_in();
}
Specific Role Check
'permission_callback' => function ($request) {
return current_user_can('edit_others_posts');
}
Token Verification (for Apps/Frontends)
'permission_callback' => function ($request) {
$token = $request->get_header('X-Api-Token');
if (!$token) return false;
$user = get_users([
'meta_key' => 'api_token',
'meta_value' => hash('sha256', $token),
]);
return !empty($user);
}
Pagination: Stop Fetching Everything at Once
WordPress REST API paginates by default, 10 per page:
{
"headers": {
"X-WP-Total": 142,
"X-WP-TotalPages": 15
}
}
Frontend loops through all pages:
async function fetchAllPosts() {
const posts = [];
let page = 1;
while (true) {
const res = await fetch(`/wp-json/wp/v2/posts?page=${page}&per_page=100`);
const data = await res.json();
if (!data.length) break;
posts.push(...data);
page++;
}
return posts;
}
Custom pagination parameters:
register_rest_route('tech/v1', '/search', [
'methods' => 'GET',
'callback' => function ($request) {
$paged = (int) $request->get_param('page') ?: 1;
$per_page = min((int) $request->get_param('per_page') ?: 10, 100);
$query = new WP_Query([
's' => sanitize_text_field($request['s'] ?? ''),
'post_type' => 'post',
'paged' => $paged,
'posts_per_page' => $per_page,
]);
return [
'items' => array_map(fn($p) => [
'id' => $p->ID,
'title' => $p->post_title,
'link' => get_permalink($p->ID),
], $p->posts),
'total' => $p->found_posts,
'pages' => $p->max_num_pages,
];
},
]);
Frontend Integration: Native Fetch or Axios
Native fetch is sufficient, don't install Axios just because you can:
// Read public data, no auth needed
const res = await fetch('/wp-json/wp/v2/posts?per_page=5');
const posts = await res.json();
// Write operations need nonce
const nonce = document.querySelector('#wp-rest-nonce')?.content;
await fetch('/wp-json/wp/v2/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
body: JSON.stringify({
title: 'New Post',
content: 'Post body',
status: 'draft',
}),
});
Nonce is embedded on the frontend via wp_localize_script or directly in the page:
wp_localize_script('my-app', 'wpApi', [
'root' => esc_url_raw(rest_url()),
'nonce' => wp_create_nonce('wp_rest'),
]);
React Integration Example
import { useEffect, useState } from 'react';
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/wp-json/wp/v2/posts?per_page=10')
.then(r => r.json())
.then(data => {
setPosts(data);
setLoading(false);
});
}, []);
if (loading) return Loading...;
return (
{posts.map(p => (
-
{p.title.rendered}
))}
);
}
The 5 Pitfalls I Hit
Pitfall 1: OPTIONS Request Returns 404
CORS preflight requests need handling. In theme functions.php:
add_filter('rest_pre_serve_request', function ($response) {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, X-Api-Token');
return $response;
}, 10, 2);
Pitfall 2: Custom Endpoint Returns 401 But Doesn't Tell You Why
In permission_callback, returning WP_Error can include status code and message:
return new WP_Error(
'unauthorized',
'API token missing or invalid',
['status' => 401]
);
Pitfall 3: wp_create_nonce Returns a Value but Frontend Can't Get It
The nonce field name in wp_localize_script must be nonce to be accessible as wpApi.nonce:
wp_localize_script('my-app', 'wpApi', [
'nonce' => wp_create_nonce('wp_rest'),
// If you named it 'api_token', frontend needs wpApi.api_token
]);
Pitfall 4: Custom Post Type Registered but Not Accessible via REST
When registering CPT, show_in_rest must be set to true:
register_post_type('book', [
'labels' => ['name' => 'Books'],
'public' => true,
'show_in_rest' => true, // ← This is required
'rest_base' => 'books',
]);
Pitfall 5: Production HTTPS but API Returns Mixed Content
After WordPress site is HTTPS-enabled, internal REST API calls still return HTTP links. In wp-config.php:
$_SERVER['HTTPS'] = 'on';
define('FORCE_SSL_ADMIN', true);
Or via REST response hook:
add_filter('rest_pre_dispatch', function ($result, $server, $request) {
if (stripos($request->get_route(), '/wp-json/') === 0) {
add_filter('content_url', fn($url) => str_replace('http://', 'https://', $url));
}
return $result;
}, 10, 3);
Versioning: The Point of URL Prefixes
/wp-json/tech/v1/posts-stats ← v1, you can add v2 later
/wp-json/wp/v2/posts ← WP built-in endpoints
tech/v1 is a custom namespace, which avoids conflicts with WP built-in endpoints. WordPress REST API validates by default that namespaces conform to vendor/type format.
Complete File: Putting It All Together
'GET',
'callback' => function ($request) {
$posts = wp_count_posts();
return [
'publish' => $posts->publish,
'draft' => $posts->draft,
'total' => $posts->total,
];
},
'permission_callback' => function () {
return current_user_can('read');
},
]);
// CPT registration example (add to WordPress)
register_post_type('book', [
'public' => true,
'show_in_rest' => true,
'rest_base' => 'books',
]);
});
// CORS headers
add_filter('rest_pre_serve_request', function ($response) {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Authorization, Content-Type, X-Api-Token, X-WP-Nonce');
return $response;
}, 10, 2);
Conclusion
WordPress REST API is more reasonably designed than you might expect—custom endpoints + permission callbacks + namespace versioning, understanding these three things covers most integration work. The most common mistakes are forgetting show_in_rest (causing CPT to be inaccessible) and missing CORS configuration (every frontend developer hits this during local development).
If you're building a decoupled WordPress project, I recommend first getting your endpoints working in Postman or Insomnia, then connecting to the frontend. Don't write frontend and backend at the same time—debugging becomes painful.
👉 Try MiniMax API now: https://platform.minimaxi.com/subscribe/token-plan?code=E5yur9NOub&source=link
Verified versions: WordPress 6.9(2026-04-30 official verification), REST API Version 2.0
🔗 Related Tech Articles
Deep dive into related technical topics: