Exposing WordPress Custom Post Types to REST: Pagination, Headers, and the Cost of Deep Pages
A custom post type can be queryable in WP_Query and still invisible to REST clients. Enable show_in_rest, then paginate with built-in collection parameters and read X-WP-Total headers instead of counting pages yourself.
06 Apr 2026, 21:02 UTC

You registered a custom post type (CPT)—call it books—and it works everywhere you expect: the admin menu loads, WP_Query returns records, and the front-end templates render. Then a headless client or the block editor asks for /wp-json/wp/v2/books and receives a 404. The data exists, but the REST API cannot see it. The useful takeaway: public => true controls admin and front-end visibility, not REST exposure. You need show_in_rest, and once you enable it, you should lean on WordPress's built-in collection parameters and response headers rather than writing custom pagination logic.
Visibility is not the same as exposure
A CPT registered with public => true and publicly_queryable => true is available to WP_Query and gets rewrite rules. That does not automatically create REST routes. The REST API uses separate controllers that decide which endpoints exist and what data they return. If show_in_rest is false or omitted, the block editor may also fail to load the CPT correctly, because the editor relies on REST routes for saving and previewing.
Enabling show_in_rest is therefore an API design decision, not a template tweak. You are publishing a contract: a stable URL, a schema, and a set of fields that external clients can depend on.
Registering a stable route
Put the registration in a site-specific plugin rather than a theme functions.php file so the route survives theme changes. Set show_in_rest => true and provide a rest_base—the path segment clients will request. A stable rest_base matters because renaming it later breaks every existing integration.
register_post_type( 'book', [
'public' => true,
'show_in_rest' => true,
'rest_base' => 'books',
'supports' => [ 'title', 'editor', 'thumbnail' ],
] );
Run this once on plugin activation or after adding the code, then flush rewrite rules a single time so permalinks and REST routes are rebuilt. Do not call flush_rewrite_rules() on every page load; it is expensive. The default REST namespace is wp/v2. Only supply a custom rest_namespace when you need to version the API or avoid collisions with other plugins.
Let the controller paginate
WordPress REST collections already support per_page, page, offset, orderby, order, and _fields. You rarely need to write custom pagination SQL or client-side page counting. The response headers carry the metadata you need. Note that offset skips records directly, but it carries the same deep-skip database cost as a high page value.
From a terminal, request the second page of ten books and ask only for the fields your client uses:
curl -i "https://example.com/wp-json/wp/v2/books?per_page=10&page=2&_fields=id,title,link,date"
Replace example.com with your site URL. The -i flag includes response headers. If the CPT is not publicly queryable, this unauthenticated request will return a 401 or 403; in that case, repeat it with authentication such as an application password. Look for X-WP-Total and X-WP-TotalPages; these tell you the total record count and the number of available pages without fetching every page and counting locally.
_fields is a useful payload reducer, but it is not a security control. It trims the JSON response on the server; it does not replace capability checks or careful meta exposure. If your CPT stores sensitive data in post meta, do not assume it is automatically hidden or shown. Register meta explicitly with register_meta(), set show_in_rest => true, and supply a permission_callback that returns true only for users allowed to read that field.
The trade-off: stateless offset pagination gets expensive
REST pagination is stateless and cache-friendly: each request stands alone, which makes CDN and reverse-proxy caching straightforward. The cost appears on deep pages. When you ask for page=50, the database must count matching rows and skip the first 490. If your ordering is not stable—say, many posts share the same timestamp and no secondary sort key is applied—records can shift between pages while a client is browsing.
For most directories and archives this is acceptable. For very large datasets, consider whether a custom endpoint with keyset (cursor) pagination is warranted, or at least enforce a deterministic orderby such as date plus ID so page boundaries stay consistent. Also remember that per_page is controller-limited. A request for per_page=100 may be clamped to a lower maximum, and a page beyond the available range can return a rest_invalid_page_number error. Test these boundaries on your own WordPress version rather than assuming defaults.
Check the result before you ship
- Open
/wp-json/wp/v2/books/schemain a browser to confirm the route and expected fields are registered. - Request page 1 and page 2 with identical parameters and compare
X-WP-Total,X-WP-TotalPages, and the returned IDs to verify stable ordering. - Test
per_page=1,per_page=100, and an out-of-range page to observe clamping or the exact error code your site returns. - Load the block editor for the CPT and issue an unauthenticated REST request to ensure only intended public fields appear.
Treat the rest_base as public API. If you must change it later, version the route through a custom namespace instead of silently renaming the path. Plugins and site configuration can also alter or disable REST responses, so identical code may behave differently across environments—verify on staging before relying on it in production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.