📚 Related Reading

← Back to Home

WordPress REST API Development Complete Guide

WordPressREST APIAPI DevelopmentFrontend IntegrationReact

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:

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 ( ); }

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:

WordPress REST API Development Complete Guide
技术标签: rest api, api development
WordPress REST API 实战开发完整指南
技术标签: rest api, api开发
WordPress REST API 实战开发完整指南
技术标签: rest api, api开发
🌐 WordPress Hosting
查看推荐 →