Extending Core Routes
Add fields to core endpoints with register_rest_field, expose meta and post types via show_in_rest, and filter rest_prepare_{$type}.
register_rest_field( string|array $object_type, string $attribute, array $args = array() )Most of the time you do not need a new route — you need /wp/v2/posts to carry one more piece of data. There are three ways in, and they are not interchangeable.
show_in_rest
Registration arguments decide what exists at all. register_post_type() and register_taxonomy() take 'show_in_rest' => true (WordPress 4.7), plus rest_base, rest_controller_class, and rest_namespace since 5.9. register_post_meta() takes the same key, and the meta then appears inside the response's meta object. The shorthand 'show_in_rest' => true is equivalent to 'show_in_rest' => array() with a schema inferred from type; the longhand array( 'schema' => array( … ) ) is required for array and object meta (WordPress 5.3), where you must describe items or properties yourself.
register_rest_field()
Introduced in WordPress 4.7, this adds a top-level field to an object type — post, page, term, comment, user — with a get_callback, an optional update_callback and a schema. Call it on rest_api_init. Use it for derived or computed values that are not a stored meta key.
rest_prepare_{$post_type}
The filter rest_prepare_post, rest_prepare_page and so on (WordPress 4.7) receives ( WP_REST_Response $response, WP_Post $post, WP_REST_Request $request ) after the controller has built the response. Use it to remove or rewrite what core already produced — it is the only one of the three that can take something away.
Example
<?php
add_action( 'init', 'acme_register_book_meta' );
function acme_register_book_meta() {
// Shorthand: a scalar, single value. The REST schema is inferred from 'type'.
register_post_meta(
'book',
'acme_isbn',
array(
'type' => 'string',
'description' => __( 'ISBN-13.', 'acme' ),
'single' => true,
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
// Runs during the edit_post_meta / add_post_meta / delete_post_meta
// capability checks, so it gates writes only -- never reads. Left
// out, it defaults to __return_true for an unprotected key and
// __return_false for an _underscore-prefixed (protected) one.
'auth_callback' => function ( $allowed, $meta_key, $post_id ) {
return current_user_can( 'edit_post', $post_id );
},
)
);
// Longhand: array meta MUST spell out the schema, including items.
// Since WordPress 5.3. Omit it and register_post_meta() returns false.
register_post_meta(
'book',
'acme_translators',
array(
'type' => 'array',
'single' => true,
'show_in_rest' => array(
'schema' => array(
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
),
)
);
}
// Both appear under "meta" in GET /wp-json/wp/v2/books/<id>:
// "meta": { "acme_isbn": "9780000000000", "acme_translators": [ "A. Smith" ] }
// The post type must have been registered with
// 'show_in_rest' => true, 'supports' => array( 'title', 'editor', 'custom-fields' )When to use it
- Exposing a custom post type and its meta so the block editor can save them.
- Adding a computed field, such as reading time or a signed download URL, to /wp/v2/posts.
- Removing fields a public JSON feed should not carry before it hits a CDN.
- Letting a headless front end write a custom meta key with a normal POST to wp/v2.
More examples
A computed top-level field with register_rest_field()
Use register_rest_field for values you derive rather than store. Note the get_callback receives the prepared response data as an array, not a WP_Post, so reach for $post['id'] and then whatever getter you need. Supplying schema is what makes the field visible to _fields, to the OPTIONS request, and to the block editor's data store.
<?php
add_action(
'rest_api_init',
function () {
register_rest_field(
'post', // object type; also 'page', 'term', 'comment', 'user'
'reading_time', // becomes a top-level key in the response
array(
// ( array $post, string $field_name, WP_REST_Request $request, string $object_type )
'get_callback' => function ( $post, $field_name, $request ) {
$words = str_word_count( wp_strip_all_tags( get_post_field( 'post_content', $post['id'] ) ) );
return (int) ceil( $words / 200 );
},
// Read-only: with no update_callback, a value sent for this
// field on write is silently skipped, not rejected with an error.
'update_callback' => null,
'schema' => array(
'description' => __( 'Estimated reading time in minutes.', 'acme' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
)
);
}
);Trimming a core response with rest_prepare_{$post_type}
This filter is the only one of the three approaches that can subtract. Reach for it to slim a public payload, to conditionally redact a field, or to correct something a core controller produced — but check the context parameter first, because the block editor reads the same route with context=edit and breaks if you remove fields it expects.
<?php
/**
* Hide the author's email-derived data and drop an unused block from the
* payload of a public, cacheable posts endpoint.
*
* Parameters, in order: WP_REST_Response, WP_Post, WP_REST_Request. Since 4.7.
*/
add_filter(
'rest_prepare_post',
function ( $response, $post, $request ) {
// Only touch the public read context; leave the editor's payload alone.
if ( 'edit' === $request->get_param( 'context' ) ) {
return $response;
}
$data = $response->get_data();
unset( $data['guid'], $data['ping_status'], $data['to_ping'] );
$response->set_data( $data );
// _links are separate from the data array and are removed by relation.
$response->remove_link( 'https://api.w.org/attachment' );
return $response;
},
10,
3
);
Discussion