WordPress REST API — Read, Create, Update and Custom Endpoints Guide
In this tutorial, you'll learn to use the WordPress REST API to read, create, and update data remotely using JSON, build custom endpoints with register_rest_route(), and connect WordPress to JavaScript frontend applications.
What You'll Learn
- What the REST API is and how it exposes WordPress data as JSON
- Core routes: /wp/v2/posts, /wp/v2/pages, /wp/v2/categories, /wp/v2/users, /wp/v2/media
- Making GET requests from the browser, curl, and JavaScript fetch
- Understanding the response structure: id, title, content, excerpt, date, slug, meta, featured_media
- Making POST requests to create and update posts
- Authentication: Cookie/Nonce, Application Passwords, OAuth
- Creating custom endpoints with register_rest_route()
- REST API permissions and capability checks
- Building headless WordPress with React, Next.js, and Vue
- CORS, Caching, and REST API best practices
Why It Matters
Before the REST API, getting data out of WordPress meant writing custom PHP files, querying the database directly, or scraping HTML. The REST API (added in WordPress 4.7) exposes all WordPress data as JSON — the universal data format of the web. This means you can build mobile apps that read your blog posts, create a React-based frontend that fetches pages from WordPress, or connect WordPress to external services like Zapier and Slack. Without the REST API, headless WordPress — using WordPress as a backend with a separate frontend — would be impossible.
Real-World Use
A news website wants to build a mobile app that shows the latest articles. Instead of building a separate backend, the app fetches JSON from https://example.com/wp-json/wp/v2/posts. The same endpoint powers a React-based homepage that loads faster than the PHP-rendered version, and an automation workflow in Make.com that creates posts from Google Sheets data. One REST API, three different use cases.
Learning Path
flowchart LR
A[Shortcodes] --> B[REST API]
B --> C[Localization]
B --> D[WooCommerce Setup]
C --> E[Translating Plugins]
D --> F[Products & Inventory]
style B fill:#4a90d9,color:#fff
What Is the REST API?
REST stands for Representational State Transfer. It is an architectural style for building web APIs that use HTTP methods (GET, POST, PUT, PATCH, DELETE) to perform CRUD operations (Create, Read, Update, Delete) on resources.
The WordPress REST API turns your WordPress site into an API-first platform. Every post, page, category, tag, user, media file, comment, and setting is available as JSON through predictable URLs called routes.
Key Concepts
| Term | Meaning |
|---|---|
| Route | A URL pattern that maps to a resource, like /wp/v2/posts |
| Endpoint | A specific combination of route + HTTP method, like GET /wp/v2/posts |
| Resource | The data object, like a post or a category |
| Schema | The structure of the resource, showing available fields and types |
| Namespace | The prefix for a group of routes, like wp/v2 or a custom namespace |
How It Works
When you visit https://example.com/wp-json/wp/v2/posts in a browser, WordPress returns a JSON array of recent posts instead of an HTML page. The same URL works everywhere — in JavaScript, curl, Python, or any HTTP client.
// WordPress automatically creates REST API routes for:
// - Posts: /wp/v2/posts
// - Pages: /wp/v2/pages
// - Categories:/wp/v2/categories
// - Tags: /wp/v2/tags
// - Users: /wp/v2/users
// - Media: /wp/v2/media
// - Comments: /wp/v2/comments
// - Settings: /wp/v2/settings
// - Post Types:/wp/v2/types
// - Taxonomies:/wp/v2/taxonomies
Core Routes
The WordPress REST API comes with built-in routes for all default content types.
Posts
GET /wp/v2/posts → List of posts
GET /wp/v2/posts/{id} → Single post by ID
GET /wp/v2/posts?slug={slug} → Single post by slug
GET /wp/v2/posts?search={term} → Search posts
GET /wp/v2/posts?categories={id} → Posts in a category
GET /wp/v2/posts?per_page=20&page=2 → Pagination
Pages
GET /wp/v2/pages → List of pages
GET /wp/v2/pages/{id} → Single page by ID
GET /wp/v2/pages?parent={id} → Child pages
Categories and Tags
GET /wp/v2/categories → List of categories
GET /wp/v2/categories/{id} → Single category
GET /wp/v2/tags → List of tags
GET /wp/v2/tags/{id} → Single tag
Users and Media
GET /wp/v2/users → List of users
GET /wp/v2/users/{id} → Single user (limited fields for non-editors)
GET /wp/v2/media → List of media items
GET /wp/v2/media/{id} → Single media item
Discovery
WordPress exposes all available routes at a single discovery endpoint:
GET /wp-json/ → Complete route list (the API index)
GET /wp-json/wp/v2 → Routes under wp/v2 namespace
Making GET Requests
You can access REST API data from any HTTP client. Let's look at three common approaches.
In the Browser
Open your browser and visit:
https://yoursite.com/wp-json/wp/v2/posts
The browser displays a JSON array of your most recent posts. Every WordPress site has this endpoint enabled by default — no authentication needed for reading public data.
With curl
# Get the 5 most recent posts
curl https://yoursite.com/wp-json/wp/v2/posts?per_page=5
# Get a single post by ID
curl https://yoursite.com/wp-json/wp/v2/posts/42
# Get posts in a specific category
curl "https://yoursite.com/wp-json/wp/v2/posts?categories=3"
# Get posts with embedded author and featured media data
curl "https://yoursite.com/wp-json/wp/v2/posts?_embed"
# Pretty-print the output
curl https://yoursite.com/wp-json/wp/v2/posts | json_pp
Expected output (abbreviated):
[
{
"id": 42,
"date": "2026-06-27T10:00:00",
"slug": "hello-world",
"title": {
"rendered": "Hello World"
},
"content": {
"rendered": "<p>Welcome to WordPress.</p>",
"protected": false
},
"excerpt": {
"rendered": "<p>Welcome to WordPress.</p>",
"protected": false
},
"featured_media": 0,
"categories": [1],
"tags": [],
"_links": {
"self": [{"href": "https://yoursite.com/wp-json/wp/v2/posts/42"}]
}
}
]
With JavaScript fetch
// Fetch the latest 10 posts
fetch('https://yoursite.com/wp-json/wp/v2/posts?per_page=10')
.then(response => response.json())
.then(posts => {
posts.forEach(post => {
console.log(post.title.rendered);
console.log(post.excerpt.rendered);
});
})
.catch(error => console.error('Error:', error));
With async/await
async function getPosts() {
try {
const response = await fetch('https://yoursite.com/wp-json/wp/v2/posts');
const posts = await response.json();
return posts;
} catch (error) {
console.error('Failed to fetch posts:', error);
}
}
getPosts().then(posts => {
// Do something with posts
});
Response Structure
Understanding the response fields helps you know exactly what data is available.
Post Object Fields
| Field | Type | Description |
|---|---|---|
id |
integer | Unique post identifier |
date |
string | Published date (ISO 8601) |
modified |
string | Last modified date (ISO 8601) |
slug |
string | URL-friendly name |
status |
string | publish, draft, pending, private |
type |
string | post type slug |
title.rendered |
string | Rendered post title (with HTML) |
content.rendered |
string | Full post content (rendered HTML) |
excerpt.rendered |
string | Post excerpt (rendered HTML) |
featured_media |
integer | ID of the featured image |
categories |
array | Array of category IDs |
tags |
array | Array of tag IDs |
author |
integer | User ID of the author |
meta |
object | Custom field values |
_links |
object | Related resource URLs (self, collection, author, replies) |
_embedded |
object | Embedded resources when ?_embed is used |
Using _embed
The _embed parameter tells WordPress to include linked resources (author, featured media, comments) directly in the response instead of requiring separate requests:
// With _embed, you get author name and featured image URL in one request
fetch('https://yoursite.com/wp-json/wp/v2/posts?_embed')
.then(res => res.json())
.then(posts => {
posts.forEach(post => {
// Author name is embedded
const authorName = post._embedded.author[0].name;
// Featured image URL is embedded
const featuredImage = post._embedded['wp:featuredmedia']?.[0]?.source_url;
console.log(post.title.rendered, 'by', authorName);
});
});
Using the fields Parameter
The fields or _fields parameter limits the response to specific fields, reducing bandwidth:
# Only get id, title, and date for each post
curl "https://yoursite.com/wp-json/wp/v2/posts?_fields=id,title,date"
[
{
"id": 42,
"title": { "rendered": "Hello World" },
"date": "2026-06-27T10:00:00"
}
]
Making POST Requests
To create or update data, you need authentication and must use POST, PUT, or PATCH.
Creating a Post
curl -X POST https://yoursite.com/wp-json/wp/v2/posts \
-H "Content-Type: application/json" \
-H "Authorization: Basic base64(username:password)" \
-d '{
"title": "Created via REST API",
"content": "This post was created using the WordPress REST API.",
"status": "draft",
"categories": [1]
}'
Expected response (201 Created):
{
"id": 57,
"title": { "rendered": "Created via REST API" },
"status": "draft",
"content": { "rendered": "<p>This post was created using the WordPress REST API.</p>" }
}
Updating a Post (PATCH)
Use PATCH to update specific fields. You only send the fields you want to change:
# Publish the draft post we just created (ID 57)
curl -X PATCH https://yoursite.com/wp-json/wp/v2/posts/57 \
-H "Content-Type: application/json" \
-H "Authorization: Basic base64(username:password)" \
-d '{
"status": "publish"
}'
Updating Post Meta
# Update a custom field
curl -X PATCH https://yoursite.com/wp-json/wp/v2/posts/57 \
-H "Content-Type: application/json" \
-H "Authorization: Basic base64(username:password)" \
-d '{
"meta": {
"my_custom_field": "Updated value"
}
}'
Authentication Methods
The WordPress REST API supports three authentication methods depending on your use case.
Cookie / Nonce Authentication
Used when the request comes from the same WordPress site (e.g., JavaScript in the admin area). WordPress sets a login cookie, and you pass a nonce (one-time token) for CSRF protection:
// In a WordPress admin page or theme template
const wpApiSettings = {
root: 'https://yoursite.com/wp-json/wp/v2/',
nonce: 'your-nonce-here' // Generated by wp_create_nonce('wp_rest')
};
fetch(wpApiSettings.root + 'posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce
},
body: JSON.stringify({
title: 'New Post',
status: 'draft'
})
});
Generate the nonce in PHP:
// In functions.php or a template
wp_localize_script('my-script', 'wpApiSettings', array(
'root' => esc_url_raw(rest_url()),
'nonce' => wp_create_nonce('wp_rest')
));
Application Passwords
Application Passwords (introduced in WordPress 5.6) are the recommended method for external apps. Each application gets a unique password that can be revoked independently.
Set up in the WordPress admin: Users > Profile > Application Passwords
# Add a new application password, then use it like a regular password
curl -X POST https://yoursite.com/wp-json/wp/v2/posts \
-H "Content-Type: application/json" \
-u "username:application_password" \
-d '{"title": "From an external app", "status": "publish"}'
The -u flag sends a Basic Authorization header with base64(username:application_password).
OAuth Authentication
For third-party applications that need to act on behalf of users, OAuth 1.0a is supported via the OAuth 1.0a plugin. This is the standard protocol for services like Zapier, IFTTT, and mobile apps.
OAuth workflow:
- Request a temporary token
- User authorizes the token
- Exchange for an access token
- Use the access token for API requests
Most developers use the Application Passwords method instead of OAuth because it is simpler and built into WordPress core.
Custom Endpoints with register_rest_route()
When built-in routes are not enough, you can create custom endpoints using register_rest_route() in your theme's functions.php or a custom plugin.
Basic Custom Endpoint
// Register a custom route
add_action('rest_api_init', function () {
register_rest_route('myplugin/v1', '/latest-posts/', array(
'methods' => 'GET',
'callback' => 'myplugin_get_latest_posts',
));
});
// Callback function
function myplugin_get_latest_posts() {
$posts = get_posts(array(
'numberposts' => 5,
'post_status' => 'publish',
));
if (empty($posts)) {
return new WP_REST_Response(array(
'message' => 'No posts found',
'posts' => array(),
), 200);
}
$data = array();
foreach ($posts as $post) {
$data[] = array(
'id' => $post->ID,
'title' => get_the_title($post),
'link' => get_permalink($post),
'date' => $post->post_date,
'excerpt' => get_the_excerpt($post),
);
}
return new WP_REST_Response($data, 200);
}
Now you can access GET https://yoursite.com/wp-json/myplugin/v1/latest-posts/ and get a custom-formatted JSON response.
Endpoint with URL Parameters
add_action('rest_api_init', function () {
register_rest_route('myplugin/v1', '/featured-products/(?P<category_id>\d+)', array(
'methods' => 'GET',
'callback' => 'myplugin_get_featured_products',
'args' => array(
'category_id' => array(
'required' => true,
'validate_callback' => function ($param) {
return is_numeric($param);
},
'sanitize_callback' => 'absint',
),
'per_page' => array(
'default' => 10,
'validate_callback' => function ($param) {
return is_numeric($param) && $param > 0 && $param <= 100;
},
),
),
));
});
function myplugin_get_featured_products($request) {
$category_id = $request->get_param('category_id');
$per_page = $request->get_param('per_page');
$products = get_posts(array(
'post_type' => 'product',
'posts_per_page' => $per_page,
'tax_query' => array(
array(
'taxonomy' => 'category',
'field' => 'term_id',
'terms' => $category_id,
),
),
));
$data = array();
foreach ($products as $product) {
$data[] = array(
'id' => $product->ID,
'name' => get_the_title($product),
'price' => get_post_meta($product->ID, '_price', true),
'permalink' => get_permalink($product),
);
}
return new WP_REST_Response($data, 200);
}
Endpoint with Permission Callback
add_action('rest_api_init', function () {
register_rest_route('myplugin/v1', '/submit-feedback/', array(
'methods' => 'POST',
'callback' => 'myplugin_handle_feedback',
'permission_callback' => function () {
return is_user_logged_in();
},
'args' => array(
'rating' => array(
'required' => true,
'validate_callback' => function ($param) {
return is_numeric($param) && $param >= 1 && $param <= 5;
},
),
'message' => array(
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
),
),
));
});
function myplugin_handle_feedback($request) {
$rating = $request->get_param('rating');
$message = $request->get_param('message');
$user_id = get_current_user_id();
$feedback_id = wp_insert_post(array(
'post_type' => 'feedback',
'post_title' => 'Feedback from user ' . $user_id,
'post_content'=> $message,
'post_status' => 'publish',
'meta_input' => array(
'_feedback_rating' => $rating,
'_feedback_user' => $user_id,
),
));
if (is_wp_error($feedback_id)) {
return new WP_Error('feedback_failed', 'Could not save feedback', array('status' => 500));
}
return new WP_REST_Response(array(
'message' => 'Thank you for your feedback!',
'id' => $feedback_id,
), 201);
}
Complete register_rest_route() Reference
register_rest_route(
'myplugin/v1', // Namespace (plugin name + version)
'/items/(?P<id>\d+)', // Route with regex parameter
array(
// Multiple methods for the same route
array(
'methods' => 'GET',
'callback' => 'myplugin_get_item',
'permission_callback' => '__return_true', // Public
),
array(
'methods' => 'POST',
'callback' => 'myplugin_update_item',
'permission_callback' => function () {
return current_user_can('edit_posts');
},
),
array(
'methods' => 'DELETE',
'callback' => 'myplugin_delete_item',
'permission_callback' => function () {
return current_user_can('delete_posts');
},
),
)
);
Route Registration Parameters
| Parameter | Description |
|---|---|
namespace |
Vendor prefix + version, like myplugin/v1 |
route |
URL path, supports regex with (?P<name>pattern) |
methods |
HTTP method(s): GET, POST, PUT, PATCH, DELETE |
callback |
Function that returns the response |
permission_callback |
Function that returns true/false for access |
args |
Array of argument definitions with validation/sanitization |
REST API Permissions
Every endpoint needs a permission_callback. For public data, use __return_true. For protected data, check user capabilities.
Common Permission Callbacks
// Public — anyone can access
'permission_callback' => '__return_true'
// Logged-in users only
'permission_callback' => function () {
return is_user_logged_in();
}
// Users who can edit posts
'permission_callback' => function () {
return current_user_can('edit_posts');
}
// Users who can publish posts
'permission_callback' => function () {
return current_user_can('publish_posts');
}
// Administrators only
'permission_callback' => function () {
return current_user_can('manage_options');
}
// Custom capability
'permission_callback' => function () {
return current_user_can('my_custom_capability');
}
Built-in Route Permissions
| Route | Read | Create | Update | Delete |
|---|---|---|---|---|
/wp/v2/posts |
Public | edit_posts |
edit_posts |
delete_posts |
/wp/v2/pages |
Public | edit_pages |
edit_pages |
delete_pages |
/wp/v2/users |
Public (limited) | create_users |
edit_users |
delete_users |
/wp/v2/media |
Public | upload_files |
edit_posts |
delete_posts |
/wp/v2/settings |
manage_options |
N/A | manage_options |
N/A |
JavaScript Fetch Examples
Here are practical examples for common REST API operations from the browser.
Getting Posts and Rendering Them
async function fetchAndRenderPosts() {
try {
const response = await fetch(
'https://yoursite.com/wp-json/wp/v2/posts?_embed&per_page=5'
);
const posts = await response.json();
const container = document.getElementById('posts-container');
posts.forEach(post => {
const title = post.title.rendered;
const excerpt = post.excerpt.rendered;
const link = post.link;
const authorName = post._embedded.author[0].name;
const date = new Date(post.date).toLocaleDateString();
container.innerHTML += `
<article>
<h2><a href="${link}">${title}</a></h2>
<p class="meta">By ${authorName} on ${date}</p>
<div>${excerpt}</div>
</article>
`;
});
} catch (error) {
console.error('Error fetching posts:', error);
}
}
fetchAndRenderPosts();
Creating a Post from a Form
document.getElementById('post-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
const response = await fetch('https://yoursite.com/wp-json/wp/v2/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce
},
body: JSON.stringify({
title: formData.get('title'),
content: formData.get('content'),
status: 'draft',
categories: [Number(formData.get('category'))]
})
});
const result = await response.json();
if (response.ok) {
console.log('Post created with ID:', result.id);
alert('Post created successfully!');
} else {
console.error('Error:', result);
alert('Failed to create post: ' + result.message);
}
} catch (error) {
console.error('Network error:', error);
}
});
Updating a Post with PATCH
async function updatePostTitle(postId, newTitle) {
try {
const response = await fetch(
`https://yoursite.com/wp-json/wp/v2/posts/${postId}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpApiSettings.nonce
},
body: JSON.stringify({
title: newTitle
})
}
);
const updatedPost = await response.json();
if (response.ok) {
console.log('Post updated:', updatedPost.title.rendered);
} else {
console.error('Update failed:', updatedPost);
}
} catch (error) {
console.error('Error updating post:', error);
}
}
// Usage: updatePostTitle(42, 'Updated Title via API');
Headless WordPress
Headless WordPress means using WordPress as a backend content management system while building the frontend with a separate framework. The REST API is the bridge between them.
Why Go Headless?
| Reason | Explanation |
|---|---|
| Performance | JavaScript frontends can be static or server-side rendered, often faster than PHP |
| Security | The frontend exposes no WordPress attack surface |
| Developer Experience | Use React, Vue, or any framework your team knows |
| Omnichannel | One WordPress backend powers web, mobile, and IoT |
React with WordPress REST API
import { useState, useEffect } from 'react';
function BlogPosts() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('https://yoursite.com/wp-json/wp/v2/posts?_embed')
.then(res => res.json())
.then(data => {
setPosts(data);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
return (
<div className="posts">
{posts.map(post => (
<article key={post.id}>
<h2 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
<div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
</article>
))}
</div>
);
}
Next.js with WordPress
// pages/posts.js — fetches posts at build time
export async function getStaticProps() {
const res = await fetch('https://yoursite.com/wp-json/wp/v2/posts?_embed');
const posts = await res.json();
return {
props: { posts },
revalidate: 3600 // Regenerate page every hour
};
}
export default function Posts({ posts }) {
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title.rendered}</h2>
<div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
</article>
))}
</div>
);
}
Vue.js with WordPress
// In a Vue component
export default {
data() {
return {
posts: [],
loading: true
};
},
mounted() {
fetch('https://yoursite.com/wp-json/wp/v2/posts?_embed')
.then(res => res.json())
.then(data => {
this.posts = data;
this.loading = false;
});
},
template: `
<div v-if="loading">Loading...</div>
<div v-else>
<article v-for="post in posts" :key="post.id">
<h2 v-html="post.title.rendered"></h2>
<div v-html="post.excerpt.rendered"></div>
</article>
</div>
`
};
CORS
Cross-Origin Resource Sharing (CORS) controls whether a browser can make requests from one domain to another. By default, WordPress blocks cross-origin requests from unknown domains.
Adding CORS Headers
Add this to your theme's functions.php or a custom plugin:
// Allow all origins (development only — restrict in production)
add_action('rest_api_init', function () {
remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
add_filter('rest_pre_serve_request', function ($value) {
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Type');
// Handle preflight OPTIONS request
if ('OPTIONS' === $_SERVER['REQUEST_METHOD']) {
status_header(200);
exit;
}
return $value;
});
});
For production, restrict to specific origins:
add_filter('rest_pre_serve_request', function ($value) {
$allowed_origins = array(
'https://myfrontend.com',
'https://app.myfrontend.com',
);
$origin = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : '';
if (in_array($origin, $allowed_origins)) {
header('Access-Control-Allow-Origin: ' . $origin);
}
return $value;
});
REST API Caching
By default, WordPress REST API responses are not cached. For high-traffic sites, caching is essential.
Caching with Transients
add_action('rest_api_init', function () {
register_rest_route('myplugin/v1', '/popular-posts/', array(
'methods' => 'GET',
'callback' => 'myplugin_get_popular_posts_cached',
));
});
function myplugin_get_popular_posts_cached() {
// Try to get cached data
$cached = get_transient('popular_posts_cache');
if (false !== $cached) {
return new WP_REST_Response($cached, 200);
}
// Generate fresh data
$posts = get_posts(array(
'meta_key' => 'post_views_count',
'orderby' => 'meta_value_num',
'order' => 'DESC',
'numberposts' => 10,
));
$data = array();
foreach ($posts as $post) {
$data[] = array(
'id' => $post->ID,
'title' => get_the_title($post),
'views' => get_post_meta($post->ID, 'post_views_count', true),
);
}
// Cache for 1 hour
set_transient('popular_posts_cache', $data, HOUR_IN_SECONDS);
return new WP_REST_Response($data, 200);
}
// Clear cache when a post is viewed
add_action('wp_head', function () {
if (is_single()) {
delete_transient('popular_posts_cache');
}
});
Response Caching Headers
add_action('rest_api_init', function () {
register_rest_route('myplugin/v1', '/menu/', array(
'methods' => 'GET',
'callback' => function () {
$menu = wp_get_nav_menu_items('primary');
$data = array();
foreach ($menu as $item) {
$data[] = array(
'title' => $item->title,
'url' => $item->url,
);
}
$response = new WP_REST_Response($data, 200);
// Cache for 15 minutes in the browser and CDN
$response->header('Cache-Control', 'public, max-age=900');
return $response;
},
));
});
REST API Best Practices
Pagination
Always paginate large result sets. Use the per_page and page parameters:
# Page 2 of 100 posts per page
curl "https://yoursite.com/wp-json/wp/v2/posts?per_page=100&page=2"
The response headers include pagination info:
X-WP-Total: 450 # Total number of posts
X-WP-TotalPages: 5 # Total pages
Link: <...>; rel="next" # Link to next page
Using _fields to Minimize Response Size
# Only get the fields you need
curl "https://yoursite.com/wp-json/wp/v2/posts?_fields=id,title.rendered,date"
Always Use _embed for Related Data
Instead of making separate requests for author and featured image, use _embed:
// One request instead of three
fetch('https://yoursite.com/wp-json/wp/v2/posts?_embed')
Validate and Sanitize All Input
Every custom endpoint must validate and sanitize arguments:
'args' => array(
'email' => array(
'required' => true,
'validate_callback' => function ($param) {
return is_email($param);
},
'sanitize_callback' => 'sanitize_email',
),
'count' => array(
'default' => 10,
'sanitize_callback' => 'absint',
),
)
Use Proper HTTP Status Codes
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Successful GET or PATCH |
| 201 | Created | Successful POST (new resource) |
| 400 | Bad Request | Invalid parameters |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but no permission |
| 404 | Not Found | Resource does not exist |
| 500 | Server Error | Unexpected failure |
// 201 Created
return new WP_REST_Response($data, 201);
// 400 Bad Request
return new WP_Error('invalid_param', 'Invalid email address', array(
'status' => 400
));
// 404 Not Found
return new WP_Error('not_found', 'Post not found', array(
'status' => 404
));
Common Mistakes
Forgetting permission_callback: Without
permission_callback, the endpoint defaults to__return_trueif not explicitly set. Since WordPress 5.5, a_doing_it_wrongnotice warns you if it's missing. Always provide one, even if it's__return_truefor public endpoints.Not sanitizing input: Accepting raw user input without sanitization creates security vulnerabilities. Always use
sanitize_callbackandvalidate_callbackin your route arguments.Calling wp_reset_query() inside REST callbacks: REST API callbacks run in a different context than template files. Do not use
wp_reset_query()— it can interfere with the main REST query. Instead, usewp_reset_postdata()after customWP_Querycalls.Returning HTML directly instead of using WP_REST_Response: Always return a
WP_REST_Response,WP_Error, or a data array from your callback. WordPress serializes these properly to JSON. Returning raw HTML breaks the API contract.Assuming JavaScript runs on the same origin: When building headless (separate frontend and backend), you need CORS headers on the WordPress side. Without them, the browser blocks cross-origin requests. Always configure CORS explicitly.
Practice Questions
What URL do you visit to discover all available REST API routes on a WordPress site?
What HTTP method do you use to update only specific fields of a post (not the entire post)?
What is the difference between Cookie/Nonce authentication and Application Passwords? When would you use each?
What does the
_embedparameter do, and why would you use it?What does the
permission_callbackparameter do inregister_rest_route()?
Challenge: Create a custom REST API endpoint at myplugin/v1/stats that returns the total number of published posts, total number of published pages, total number of categories, and total number of registered users. Add caching with transients that expires every 5 minutes.
FAQ
Mini Project
Build a headless WordPress feature with these steps:
Create a custom REST API endpoint at
myplugin/v1/featured-poststhat returns the 3 most recent posts with their featured image URLs, author names, and excerpt (not full content). Include proper permission callback and argument validation.Create an HTML page with embedded JavaScript (no framework) that fetches from this endpoint and renders the posts as cards with the featured image, title, excerpt, and author name.
Add a "Load More" button that fetches the next 3 posts using pagination.
Add Application Password authentication to a separate endpoint at
myplugin/v1/subscribethat accepts an email address, validates it, and stores it as a WordPress option (simulating a newsletter signup).Test all endpoints with curl commands.
What's Next
Now that you understand the REST API, learn how to make themes and plugins translatable with localization and Internationalization. Then explore WooCommerce Setup to build an online store with REST API integration.
For more on JavaScript and WordPress, see the Custom Post Types tutorial to expose custom content types through the REST API, and Hooks to learn how actions and filters work behind the scenes.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro