If the WordPress REST API does not return the data you need, solve the smallest problem first. Check the built-in endpoints and fields, then expose registered meta if that is enough, and only then create a custom route with an explicit permission rule.
Check the built-in posts endpoint first
The public post list is usually available at /wp-json/wp/v2/posts, and a single post at /wp-json/wp/v2/posts/<id>. The WordPress REST API Posts endpoint documents fields such as id, link, modified, content, excerpt, categories, tags, and meta.
https://example.com/wp-json/wp/v2/posts?per_page=5returns recent published posts.https://example.com/wp-json/wp/v2/posts?search=keywordsearches public posts.https://example.com/wp-json/wp/v2/posts?modified_after=2026-01-01T00:00:00asks for posts modified after a date.https://example.com/wp-json/wp/v2/posts?_fields=id,title,link,modifiedkeeps the response small without changing the endpoint.
If you need private posts, drafts, edit-only fields, or raw unfiltered content, the missing piece is usually authentication and capability, not direct database access.
Expose custom fields deliberately
Many “JSON API cannot get my data” cases are custom field cases. The WordPress guide to modifying REST responses explains two safer options: use register_post_meta() or register_meta() with show_in_rest and schema for registered meta, or use register_rest_field() for a computed read-only value. Avoid changing or removing core response fields; use _fields or add your own field instead.
Use a custom route for a purpose-built dataset
A custom route is appropriate when a front end needs a specific public collection, for example a small list of public tours or articles. The WordPress guide to adding custom REST endpoints shows that a route should be registered on rest_api_init, have a namespace such as ggs/v1, define methods, callback, arguments, validation/sanitization, and a permission_callback.
In WordPress core, a single endpoint with a callback is wrapped into an indexed endpoint array by register_rest_route(). Route options such as schema belong at the non-numeric route-options level, for example array( array( ...endpoint... ), 'schema' => ... ). WP_REST_Server::get_routes() later moves non-numeric keys into route options.
Example read-only plugin
Save the file as wp-content/plugins/ggs-public-rest-example/ggs-public-rest-example.php, then activate “GGS Public REST Example” from WordPress admin → Plugins in a staging site. The endpoint returns published, non-password-protected posts only; it does not expose raw database rows, options, users, credentials, or secret meta.
<?php
/**
* Plugin Name: GGS Public REST Example
* Description: Read-only REST route that exposes a small public post list.
* Version: 0.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'rest_api_init', 'ggs_register_public_posts_route' );
function ggs_register_public_posts_route() {
register_rest_route(
'ggs/v1',
'/public-posts',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'ggs_get_public_posts',
'permission_callback' => '__return_true',
'args' => array(
'per_page' => array(
'description' => 'Number of published posts to return.',
'type' => 'integer',
'default' => 5,
'minimum' => 1,
'maximum' => 20,
'validate_callback' => 'rest_validate_request_arg',
'sanitize_callback' => 'rest_sanitize_request_arg',
),
),
),
'schema' => 'ggs_public_posts_schema',
)
);
}
function ggs_get_public_posts( WP_REST_Request $request ) {
$query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'has_password' => false,
'posts_per_page' => (int) $request['per_page'],
'ignore_sticky_posts' => true,
'no_found_rows' => true,
)
);
$items = array();
foreach ( $query->posts as $post ) {
$items[] = array(
'id' => (int) $post->ID,
'title' => get_the_title( $post ),
'url' => get_permalink( $post ),
'modified' => get_post_modified_time( DATE_W3C, true, $post ),
);
}
return rest_ensure_response( $items );
}
function ggs_public_posts_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'ggs-public-posts',
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'id' => array( 'type' => 'integer' ),
'title' => array( 'type' => 'string' ),
'url' => array( 'type' => 'string', 'format' => 'uri' ),
'modified' => array( 'type' => 'string', 'format' => 'date-time' ),
),
),
);
}
Call the endpoint
https://example.com/wp-json/ggs/v1/public-posts?per_page=5
Without pretty permalinks, WordPress can route through ?rest_route=:
https://example.com/?rest_route=/ggs/v1/public-posts&per_page=5
The per_page argument is declared as an integer with minimum 1 and maximum 20, and uses WordPress REST schema callbacks rest_validate_request_arg and rest_sanitize_request_arg. Values such as 1.5, 0, and 21 should fail validation; 1 and 20 are valid.
Choose authentication by action
The WordPress REST API authentication guide separates common cases. Public read-only data can use __return_true only if every returned field is intended for anonymous visitors. Dashboard JavaScript normally uses cookie authentication with a REST nonce. Remote scripts should use Application Passwords over HTTPS. Write actions need capability checks, validation, sanitization, and useful WP_Error responses.
When the endpoint does not work
- WordPress returns
rest_no_route: check the plugin is active, the namespace and path match, and you are sending GET. If the plain?rest_route=URL works but/wp-json/does not, inspect the web server rewrite rules. - The response is HTML instead of JSON: inspect the status and response body for a login page, PHP error, cache response or security-plugin block before changing the route callback.
- The response is
[]: the example found no matching published, non-password-protected posts. A custom post type will not appear until you deliberately change the query and verify its public-data policy. - A private endpoint returns 401 or 403: check both authentication and the requested capability. Making its permission callback public is not a fix.
This example returns only the latest bounded set; it is not a full-site export. For pagination, search and broader core post fields, prefer the built-in posts endpoint rather than extending this small sample indefinitely.
Safe development order
- Try
/wp-json/wp/v2/posts, filters, and_fields. - Expose registered post meta when the missing data is meta.
- Add a read-only REST field when the value is computed from normal WordPress data.
- Create a namespaced custom route only for a purpose-built collection.
- For private or write operations, add authentication and capability checks before writing callback logic.