Extending the REST Response Instead of Building an Endpoint

You need one more field on /wp/v2/posts. register_rest_field does it in a dozen lines, keeps pagination and permissions, and does not create a second API to maintain.

Extending an existing WordPress REST API response with a custom field instead of creating a separate endpoint

The request is almost always the same. The front end is reading /wp-json/wp/v2/posts, and it needs one thing that is not in the response: a meta value, a computed count, the author’s job title, a related item’s id.

The reflex is to write a custom endpoint. It is usually the wrong reflex, because a custom endpoint means reimplementing everything the core route already does. Pagination headers. Filtering by category, author, date and search. Ordering. Status handling. Draft visibility. The _embed mechanism. Every one of those is behavior you now own, forever.

Add the field to the existing response instead. It is a dozen lines and you inherit the rest.

register_rest_field

add_action( 'rest_api_init', function () {

    register_rest_field(
        'post',                       // object type, or an array of them
        'acme_reading_time',          // the key in the JSON
        array(
            'get_callback' => static function ( array $post ) {
                $content = get_post_field( 'post_content', $post['id'] );
                $words   = str_word_count( wp_strip_all_tags( $content ) );
                return (int) ceil( $words / 200 );
            },
            'update_callback' => null,
            'schema' => array(
                'description' => __( 'Estimated reading time in minutes.', 'acme' ),
                'type'        => 'integer',
                'context'     => array( 'view', 'edit' ),
            ),
        )
    );
} );

The field now appears on every /wp/v2/posts response, single and collection, with no other change anywhere.

Four notes on the arguments.

rest_api_init is the hook. Same as route registration. Anywhere else and nothing happens.

The first argument is the object type, and it is the post type slug for posts, user for users, comment for comments, the taxonomy name for terms. Pass an array to register the same field on several.

get_callback receives an array, not a WP_Post. It is the response object as prepared so far. $post['id'] is there; most other things are not, so fetch what you need rather than assuming.

Prefix the field name. acme_reading_time, not reading_time. It shares a namespace with core fields and with every other plugin, and a collision here silently overwrites somebody.

The schema is worth the four extra lines

It is optional and skipping it costs you real things.

With a schema, the field is described in the route’s own OPTIONS response, so anyone consuming your API can discover it. More usefully, the _fields query parameter works, which lets a caller ask for ?_fields=id,title,acme_reading_time and get a much smaller payload. Without a schema entry, your field may be dropped from a _fields request entirely, which produces a confusing bug where the value appears in one request and not another.

context controls where it shows up: view is the public read context, edit is the editor context. Sensitive values belong in edit only.

Writable fields

Pass an update_callback and the field accepts writes on POST and PUT:

'update_callback' => static function ( $value, WP_Post $post ) {
    if ( ! current_user_can( 'edit_post', $post->ID ) ) {
        return new WP_Error( 'acme_forbidden', __( 'Not allowed.', 'acme' ),
                             array( 'status' => 403 ) );
    }
    return update_post_meta( $post->ID, '_acme_subtitle',
                             sanitize_text_field( $value ) );
},

The capability check inside update_callback is not optional. The route’s own permission callback established that the caller may edit posts at all. It did not establish that this particular field is theirs to change, and a writable field is a write endpoint whether or not it looks like one.

Return a WP_Error on refusal so the caller gets a status and a message.

The simpler path for plain meta

If all you want is a meta key exposed with no computation, you do not need register_rest_field at all:

register_post_meta( 'post', 'acme_subtitle', array(
    'type'         => 'string',
    'single'       => true,
    'show_in_rest' => true,
    'auth_callback' => static function ( $allowed, $meta_key, $post_id ) {
        return current_user_can( 'edit_post', $post_id );
    },
) );

The value appears under the meta object in the response and is writable through the same key. This is the better tool when the field really is just a stored value, and it also registers the meta properly for the block editor at the same time.

Note the underscore convention: a meta key beginning with _ is hidden from the custom fields UI, and it also needs an explicit auth callback to be exposed at all. Decide which you want before you name the key.

When a custom endpoint is genuinely right

Extending stops being the answer when:

  • The resource is not a post, a term, a user or a comment. A settings object, a report, a queue.
  • The operation is an action rather than a representation. “Recalculate”, “send”, “sync”.
  • The response is a join across several types that no single core route models.
  • The consumer is external and needs a contract that does not move when core changes.

In those cases build the route, version the namespace, and write a real permission callback for it.

Debugging, briefly

Two failures account for most of the reports.

The field is missing from the response. Check the hook first: rest_api_init, not init. Then check the object type string matches the post type slug exactly. Then check whether the caller sent _fields without your key, or whether the request is in a context your schema did not include.

The field is there and the value is wrong or null. This is a silent failure, not an error, and it responds to the same treatment as any other wrong-answer bug: assert on the value at each step rather than looking for a crash, which is the method laid out in debugging code that runs but gives the wrong result. The usual culprit is $post['id'] being read as $post->ID out of habit, or a meta key that does not match what is actually stored.

If the request never reaches your callback at all and the browser console is complaining about the request being blocked rather than about the response, that is not a WordPress problem. It is a browser policy one, and which end owns it is worked through in CORS errors explained from both ends of the request.

The short version

  • Extend the core route before you build a new one. You inherit pagination, filtering and permissions.
  • register_rest_field on rest_api_init, with a prefixed key and a schema.
  • Plain meta is better served by register_post_meta with show_in_rest.
  • Any update_callback is a write endpoint. Check the capability inside it.
  • Build a custom route when the resource or the action does not fit a core object.
Written by

Shah Alom

Shah Alom is the founder and writer behind Ebuhu, where he covers PHP, WordPress development, plugin and theme development, debugging, practical programming techniques, and AI-assisted coding. Drawing on hands-on web development experience, he focuses on clear, practical guidance that helps developers understand how things work, avoid common mistakes, and write more reliable code.

Leave a Reply

Your email address will not be published. Required fields are marked *