Type something to search...
How to Use the WordPress REST API?

How to Use the WordPress REST API?

The WordPress REST API exposes your posts, pages, media, and users as JSON, letting any application read from or write to WordPress over HTTP instead of going through the WordPress admin or a theme. It's been part of WordPress core since version 4.7 (released in 2016), so it requires no plugin to start using, and it's the foundation that makes headless WordPress possible.

This guide covers the API's core endpoints, how to query and filter them, how authentication works for anything beyond reading public content, and how to register your own custom endpoints when the built-in ones aren't enough.

How the REST API Is Structured

Every WordPress site with pretty permalinks enabled exposes its REST API under /wp-json/. The core content endpoints live under the wp/v2 namespace:

https://example.com/wp-json/wp/v2/posts
https://example.com/wp-json/wp/v2/pages
https://example.com/wp-json/wp/v2/media
https://example.com/wp-json/wp/v2/users
https://example.com/wp-json/wp/v2/categories
https://example.com/wp-json/wp/v2/tags
https://example.com/wp-json/wp/v2/comments

Each of these follows standard REST conventions: GET to read, POST to create, PUT/PATCH to update, DELETE to remove, mapped onto WordPress's existing capability system, so a request to create a post still checks whether the authenticated user is actually allowed to publish posts.

Reading Posts: The Basics

A plain GET request to the posts endpoint returns an array of published posts:

const res = await fetch("https://example.com/wp-json/wp/v2/posts");
const posts = await res.json();

console.log(posts[0].title.rendered);

Each post object includes fields like id, date, slug, status, title.rendered, content.rendered, excerpt.rendered, author (a user ID), featured_media (an attachment ID), and categories/tags (arrays of term IDs). Notice that title and content are objects with a rendered key, not plain strings, this is deliberate, since WordPress also exposes a raw variant (containing unprocessed content) when you're authenticated with edit permissions.

Filtering and Paginating Results

The posts endpoint accepts query parameters for narrowing results:

// A single post by its slug
fetch("https://example.com/wp-json/wp/v2/posts?slug=hello-world");

// Posts in a specific category (by category term ID)
fetch("https://example.com/wp-json/wp/v2/posts?categories=5");

// Page 2, 20 posts per page
fetch("https://example.com/wp-json/wp/v2/posts?page=2&per_page=20");

// Search by keyword
fetch("https://example.com/wp-json/wp/v2/posts?search=headless");

// Only return specific fields, to shrink the response payload
fetch("https://example.com/wp-json/wp/v2/posts?_fields=id,slug,title");

per_page maxes out at 100 to prevent excessively large responses; use page to fetch subsequent batches. The total number of posts and pages available is returned in the X-WP-Total and X-WP-TotalPages response headers, not in the JSON body itself, so check headers when building pagination UI:

const res = await fetch("https://example.com/wp-json/wp/v2/posts?per_page=10");
const totalPages = res.headers.get("X-WP-TotalPages");
const posts = await res.json();

Resolving Related Data with _embed

By default, a post's featured image, author, and terms come back only as numeric IDs, meaning a naive integration needs a separate request per post just to show an author name or a thumbnail. Adding _embed to the request tells WordPress to include that related data inline:

const res = await fetch(
  "https://example.com/wp-json/wp/v2/posts?slug=hello-world&_embed",
);
const [post] = await res.json();

const authorName = post._embedded?.author?.[0]?.name;
const featuredImageUrl = post._embedded?.["wp:featuredmedia"]?.[0]?.source_url;

This is the single most useful flag for reducing the number of requests a front end needs to render a post list or a single post page.

Authentication for Writing Data

Reading published, public content requires no authentication at all. Creating, updating, or deleting content, or reading private/draft content, does. WordPress core supports cookie-based authentication (useful only when your JavaScript runs on the same domain, inside the WordPress admin) plus a nonce, but for external applications the practical options are:

  • Application Passwords, built into WordPress core since version 5.6. Generate one under Users → Profile → Application Passwords in the admin, then send it as HTTP Basic Auth.
  • A JWT authentication plugin, for token-based auth better suited to single-page apps and mobile clients that shouldn't hold a long-lived password.
  • OAuth, via a plugin, for third-party applications acting on behalf of a WordPress user without ever seeing their credentials.

An authenticated request creating a new draft post using an Application Password looks like this:

const credentials = btoa("admin:xxxx xxxx xxxx xxxx xxxx xxxx");

const res = await fetch("https://example.com/wp-json/wp/v2/posts", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Basic ${credentials}`,
  },
  body: JSON.stringify({
    title: "Created via the REST API",
    content: "<p>Hello from an external script.</p>",
    status: "draft",
  }),
});

const newPost = await res.json();
console.log(newPost.id, newPost.link);

Application Passwords are the recommended default for server-to-server integrations, since they need no plugin and can be revoked individually without changing the user's actual login password.

Updating and Deleting Existing Content

Updating a post follows the same authenticated pattern, sent to the specific post's URL with POST (the REST API accepts POST as an alias for partial updates on a single resource, alongside the more strictly RESTful PUT/PATCH):

const res = await fetch("https://example.com/wp-json/wp/v2/posts/42", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Basic ${credentials}`,
  },
  body: JSON.stringify({
    status: "publish",
  }),
});

Only the fields you include in the body are changed, an update request doesn't require resending the entire post object, unspecified fields keep their existing values. Deleting works the same way with the DELETE method:

await fetch("https://example.com/wp-json/wp/v2/posts/42", {
  method: "DELETE",
  headers: { Authorization: `Basic ${credentials}` },
});

By default, DELETE moves a post to the trash rather than permanently removing it, matching how deleting a post works in the admin. Pass ?force=true on the request to bypass the trash and delete permanently.

Handling Errors and Status Codes

The REST API returns standard HTTP status codes, and error responses follow a consistent shape rather than varying endpoint to endpoint:

{
  "code": "rest_post_invalid_id",
  "message": "Invalid post ID.",
  "data": { "status": 404 }
}

A few codes worth specifically checking for in your own error handling: 401 means the request needs authentication it didn't provide; 403 means the authenticated user lacks permission for that action (trying to publish a post as a Subscriber, for example); 404 means the resource doesn't exist, or, just as often, that pretty permalinks aren't enabled and the route itself isn't recognized. Always check res.ok (or the status code directly) before assuming a response body is the data you expected, rather than an error object with the same JSON structure.

Registering a Custom Endpoint

The built-in endpoints cover core content types, but custom functionality (a contact form submission handler, a computed value not stored as post meta, data from a custom post type with a non-standard shape) often needs its own route. Register one with register_rest_route():

add_action( 'rest_api_init', function () {
    register_rest_route( 'tidewave/v1', '/featured-posts', [
        'methods'             => 'GET',
        'callback'            => 'tw_get_featured_posts',
        'permission_callback' => '__return_true',
    ] );
} );

function tw_get_featured_posts( WP_REST_Request $request ) {
    $query = new WP_Query( [
        'post_type'      => 'post',
        'posts_per_page' => 5,
        'meta_key'       => 'is_featured',
        'meta_value'     => '1',
    ] );

    $posts = array_map( function ( $post ) {
        return [
            'id'    => $post->ID,
            'title' => get_the_title( $post ),
            'link'  => get_permalink( $post ),
        ];
    }, $query->posts );

    return new WP_REST_Response( $posts, 200 );
}

This registers GET /wp-json/tidewave/v1/featured-posts as a new endpoint. A few things worth noting:

  • Always namespace custom routes (tidewave/v1 here) rather than adding to wp/v2 directly, to avoid colliding with core or plugin-registered routes.
  • permission_callback is required. WordPress will trigger a deprecation notice (and, in future versions, an error) if it's omitted. Use __return_true only for genuinely public data; otherwise check capabilities explicitly, for example current_user_can( 'edit_posts' ).
  • Return a WP_REST_Response (or a plain array, which WordPress will wrap automatically) rather than echoing JSON directly, so WordPress can apply the correct headers and status code.

Exposing Custom Fields on Existing Endpoints

Rather than building an entirely custom endpoint, you can add fields to an existing one, useful for exposing an Advanced Custom Fields value or other post meta on the standard /wp/v2/posts response:

add_action( 'rest_api_init', function () {
    register_rest_field( 'post', 'subtitle', [
        'get_callback' => function ( $post ) {
            return get_post_meta( $post['id'], 'subtitle', true );
        },
        'schema' => [
            'type'    => 'string',
            'context' => [ 'view', 'edit' ],
        ],
    ] );
} );

After this, every post in /wp-json/wp/v2/posts includes a subtitle field alongside the built-in ones, no separate request needed.

Caching Considerations

WordPress core doesn't rate-limit the REST API by default, but that doesn't mean every request should hit the database fresh. A few practical habits worth adopting on any real integration:

  • Cache responses on the consuming side. Whether that's a Next.js revalidate option (as shown in connecting WordPress to Next.js), a CDN in front of the API, or a simple in-memory cache in a script, avoid re-fetching the same unchanged data on every single page load.
  • Use _fields to shrink payloads. Requesting only the fields you actually use (?_fields=id,slug,title) reduces response size meaningfully on endpoints returning many posts at once, which matters for both speed and hosting cost on high-traffic integrations.
  • Watch for X-WP-Total growing unbounded. A query with no per_page limit against a site with thousands of posts still defaults to a sane page size, but iterating every page in a tight loop without any delay can put real load on a shared hosting environment; batch and throttle accordingly for bulk operations like a one-time migration.

Frequently Asked Questions (FAQ) About the WordPress REST API

The most common cause is permalinks set to "Plain" under Settings → Permalinks; the REST API relies on the rewrite rules that any other permalink structure sets up. Switching to a structure like "Post name" and re-saving usually fixes it. A security or firewall plugin blocking /wp-json/ routes is the second most common cause.

Yes, it's been part of WordPress core and enabled by default since version 4.7. Some security plugins or custom code disable it for unauthenticated users, which is a legitimate hardening step but will also break any headless front end or integration expecting public read access.

Only with authentication, and only for a user with permission to read them. An unauthenticated request to /wp/v2/posts never returns drafts, private posts, or posts scheduled for the future, regardless of query parameters.

The REST API returns fixed response shapes per endpoint, so fetching related data (like a featured image) usually means either a separate request or the _embed parameter. WPGraphQL lets the client specify exactly which fields it wants across related objects in a single query. See how to enable and use WPGraphQL for a direct comparison.

Custom taxonomies registered with show_in_rest set to true automatically get a matching query parameter (typically the taxonomy's slug). Filtering by arbitrary custom field values isn't supported by core out of the box; the common solution is either a custom endpoint like the one shown above or a plugin like ACF to REST API's meta query support.

Yes, but only if the post type was registered with show_in_rest set to true (and typically rest_base set to a friendly slug). Without that, the custom post type exists in WordPress but has no REST endpoint at all.

For reading published content, yes, that's exactly what it's designed for, the same content is already publicly visible on your site. The security consideration is around write access and user enumeration through the /wp/v2/users endpoint, both of which should be locked down with proper authentication and, if needed, a plugin restricting user data exposure.

Conclusion

The WordPress REST API turns every corner of WordPress, posts, pages, media, users, custom post types, into structured JSON that any application can read or write to over plain HTTP requests. Reading published content requires nothing beyond a fetch() call; writing data or reading private content requires authentication, with Application Passwords being the simplest secure option for most integrations.

Once the built-in endpoints and their query parameters feel familiar, register_rest_route() and register_rest_field() let you extend the API to fit whatever custom data your project needs, without waiting for core or a plugin to add it for you. If your use case involves fetching several related pieces of data at once, it's worth also learning WPGraphQL, which solves the over-fetching problem the REST API's fixed response shapes can create.

A few additional resources if you want to go deeper:

Tags :
Share :

Related Posts

Effortlessly Crafting Compelling WordPress Pages

Effortlessly Crafting Compelling WordPress Pages

As a website owner or content creator, having the ability to seamlessly add new pages to your WordPress site is crucial. Whether you're introducing a

Continue Reading
High Traffic Tips for WordPress Mastery 🚥

High Traffic Tips for WordPress Mastery 🚥

In our digital age, where online visibility is paramount, ensuring your WordPress site can handle surging traffic is crucial. Just like a finely-tune

Continue Reading
How Do I Change the WordPress Login URL?

How Do I Change the WordPress Login URL?

By default, every WordPress site's login page lives at the same predictable address: yoursite.com/wp-login.php (which also happens to redirect from

Continue Reading