Skip to content

WordPress REST API — Read, Create, Update and Custom Endpoints Guide

DodaTech Updated 2026-06-27 21 min read

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.

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:

  1. Request a temporary token
  2. User authorizes the token
  3. Exchange for an access token
  4. 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"

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

  1. Forgetting permission_callback: Without permission_callback, the endpoint defaults to __return_true if not explicitly set. Since WordPress 5.5, a _doing_it_wrong notice warns you if it's missing. Always provide one, even if it's __return_true for public endpoints.

  2. Not sanitizing input: Accepting raw user input without sanitization creates security vulnerabilities. Always use sanitize_callback and validate_callback in your route arguments.

  3. 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, use wp_reset_postdata() after custom WP_Query calls.

  4. 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.

  5. 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

  1. What URL do you visit to discover all available REST API routes on a WordPress site?

  2. What HTTP method do you use to update only specific fields of a post (not the entire post)?

  3. What is the difference between Cookie/Nonce authentication and Application Passwords? When would you use each?

  4. What does the _embed parameter do, and why would you use it?

  5. What does the permission_callback parameter do in register_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

### Does the WordPress REST API work on all WordPress sites?

Yes, the REST API is built into WordPress core since version 4.7 (December 2016). Every WordPress 4.7+ site has REST API routes enabled by default for all public content.

### Can I disable the REST API?

Yes, but it is not recommended. Many plugins and the block editor rely on it. If you must, use the rest_authentication_errors filter to block requests.

### How do I get a specific post by slug instead of ID?

Use the slug parameter: /wp/v2/posts?slug=my-post-slug. This returns an array with one item. You can also register a custom route that accepts the slug.

### What is the difference between PUT and PATCH?

PUT replaces the entire resource. PATCH updates only the fields you send. For the WordPress REST API, PATCH is preferred because you usually want to update specific fields without sending the full post object.

### Can I use the REST API with WooCommerce?

Yes. WooCommerce adds its own routes under the wc/v3 namespace. You can manage products, orders, customers, and more through the WooCommerce REST API.

Mini Project

Build a headless WordPress feature with these steps:

  1. Create a custom REST API endpoint at myplugin/v1/featured-posts that 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.

  2. 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.

  3. Add a "Load More" button that fetches the next 3 posts using pagination.

  4. Add Application Password authentication to a separate endpoint at myplugin/v1/subscribe that accepts an email address, validates it, and stores it as a WordPress option (simulating a newsletter signup).

  5. 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