Why Your Custom Post Type Is Missing from the REST Search Endpoint: WP_REST_Search_Handler, the wp_rest_search_handlers Filter, and the post/page Default

Your custom post type has a working REST route. GET /wp-json/wp/v2/your-cpt returns a tidy collection. The block editor loads it. Then you query GET /wp-json/wp/v2/search?search=quarterly and get back posts, pages, maybe a category term, and nothing from your CPT. The instinct is to reach for wp_rest_search_handlers and register a handler. That instinct is usually wrong, and acting on it can duplicate results rather than fix them.

The /wp/v2/search endpoint does not enumerate post types directly. It delegates to a registry of search handlers. One of those handlers, WP_REST_Post_Search_Handler, already covers every public post type that is exposed to REST. If your CPT is missing, the problem is almost always in the post type registration flags, not in the handler list.

Symptom

A request to the search endpoint returns results whose subtype values are post and page, but never your CPT slug. The same CPT appears correctly at its own collection route. The block editor’s search panel, if it uses the search endpoint, also omits it.

Two separate registries are involved, and conflating them is the source of most wasted debugging time:

  • The handler list. A site-wide array of WP_REST_Search_Handler instances, filtered by wp_rest_search_handlers. The documentation states the default is only a handler for posts. That handler’s type is post.
  • The subtype list. Built once inside WP_REST_Post_Search_Handler::__construct() from get_post_types( array( 'public' => true, 'show_in_rest' => true ), 'names' ), minus attachment.

The handler list is global and rarely needs changing. The subtype list is built once, when the default post search handler is instantiated during REST route registration, not per request. This is exactly why a register_post_type() call on a late or conditional hook can miss the list. A CPT that is absent from search results but present at its own route points at the flags, not the handler list.

Trace

Start by reading the constructor of the default post search handler. The relevant lines are short:

public function __construct() {
    $this->type = 'post';
    // Support all public post types except attachments.
    $this->subtypes = array_diff(
        array_values( get_post_types( array( 'public' => true, 'show_in_rest' => true ), 'names' ) ),
        array( 'attachment' )
    );
}

Two flags gate inclusion: public and show_in_rest. Both must be true. The register_post_type() documentation is explicit that show_in_rest controls whether the post type is included in the REST API, and that public defaults to false. A CPT registered with only show_in_rest => true and no public argument will not appear in the subtype list.

When a search request arrives with subtype any, search_items() replaces the requested post types with the handler’s subtype list:

if ( in_array( WP_REST_Search_Controller::TYPE_ANY, $post_types, true ) ) {
    $post_types = $this->subtypes;
}

That is the mechanism. If your CPT is not in $this->subtypes, it is not queried. The handler then runs a WP_Query with 'post_status' => 'publish'. A correctly registered CPT whose entries are all drafts will also appear missing. Separate the registration question from the status question before changing code.

The search results schema lists subtype values as post, page, category, and post_tag. That list is illustrative of core defaults, not exhaustive of every handler a site may register. The type parameter on the endpoint defaults to post, which is why a bare search request without a type or subtype argument behaves as a post search.

Root cause

The default post search handler already enumerates every public, REST-exposed post type. Adding a custom handler to wp_rest_search_handlers to fix a missing CPT is likely to duplicate results, because the default handler is already covering the same post type when the flags are set correctly. The filter is for object types the post handler cannot represent, not for post types it already covers.

The wp_rest_search_handlers filter is applied in create_initial_rest_routes(), which registers the default REST API routes. Each handler instance passed through the filter must extend WP_REST_Search_Handler. That is a real extension point, but it is not the first place to look when a CPT is absent.

Two failure modes produce the same symptom:

  1. Registration flags. public is false or omitted, or show_in_rest is false or omitted. The CPT is not in the subtype list, so the default handler never queries it.
  2. Status. The CPT is registered correctly, but all its entries are drafts, pending, or otherwise not publish. The handler queries with post_status => 'publish', so nothing returns.

A third, less common case: the CPT is registered on a hook that runs too late for the handler’s constructor, or the registration is conditional on a context that is not true during REST requests. The constructor runs when the handler is instantiated, which is during REST route registration. If your register_post_type() call is hooked to something that has not fired by then, the subtype list will not include it.

Fix

Diagnose in order. Do not start with the filter.

1. Inspect the registered post type object

Use WP-CLI to confirm the flags as WordPress sees them:

wp eval '
$obj = get_post_type_object( "your-cpt" );
if ( ! $obj ) {
    echo "not registered\n";
    exit;
}
echo "public: " . var_export( $obj->public, true ) . "\n";
echo "show_in_rest: " . var_export( $obj->show_in_rest, true ) . "\n";
echo "rest_base: " . $obj->rest_base . "\n";
'

If public or show_in_rest is false, that is your root cause. Fix the registration arguments. Both must be true for the default post search handler to include the CPT.

2. Inspect the search response’s subtype field

Request the search endpoint with the subtype set to your CPT slug and compare against the subtype values the response reports:

curl -s 'https://example.com/wp-json/wp/v2/search?search=quarterly&subtype=your-cpt' | jq '.subtype'

If the response is an empty array or an error, the subtype is not recognized. If it returns results, the registration is fine and the earlier absence was a status or query issue. The subtype field in the response is the observable that distinguishes “not registered as searchable” from “registered but empty.”

3. Inspect the handler list

Only after the first two checks should you look at the handler registry. A quick eval confirms what is registered:

wp eval '
$handlers = apply_filters( "wp_rest_search_handlers", array() );
foreach ( $handlers as $handler ) {
    echo get_class( $handler ) . " => " . $handler->get_type() . "\n";
    print_r( $handler->get_subtypes() );
}
'

If the default post handler is present and its subtype list includes your CPT, the handler list is not your problem. If the subtype list does not include your CPT, return to step 1.

4. When a custom handler is actually warranted

A custom handler is appropriate when you need to search an object type the post handler cannot represent: a custom database table, an external index, a non-post entity. It is not appropriate for a CPT that the post handler already covers.

WP_REST_Search_Handler declares three abstract methods that a subclass must implement:

  • search_items( WP_REST_Request $request ) — returns an associative array with RESULT_IDS (an array of found IDs) and RESULT_TOTAL (the total count).
  • prepare_item( $id, array $fields ) — returns the fields for a given item.
  • prepare_item_links( $id ) — returns links for a given item.

The base class also provides get_type() and get_subtypes(), which return the $type and $subtypes properties set by the subclass. A minimal skeleton:

class My_CPT_Search_Handler extends WP_REST_Search_Handler {
    public function __construct() {
        $this->type = 'my-cpt';
        $this->subtypes = array( 'my-cpt' );
    }

    public function search_items( WP_REST_Request $request ) {
        $query = new WP_Query( array(
            'post_type' => 'my-cpt',
            'post_status' => 'publish',
            's' => $request['search'],
            'paged' => (int) $request['page'],
            'posts_per_page' => (int) $request['per_page'],
        ) );
        return array(
            self::RESULT_IDS => wp_list_pluck( $query->posts, 'ID' ),
            self::RESULT_TOTAL => $query->found_posts,
        );
    }

    public function prepare_item( $id, array $fields ) {
        $post = get_post( $id );
        $data = array();
        if ( in_array( WP_REST_Search_Controller::PROP_ID, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_ID ] = (int) $post->ID;
        }
        if ( in_array( WP_REST_Search_Controller::PROP_TITLE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_TITLE ] = get_the_title( $post->ID );
        }
        if ( in_array( WP_REST_Search_Controller::PROP_URL, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_URL ] = get_permalink( $post->ID );
        }
        if ( in_array( WP_REST_Search_Controller::PROP_TYPE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_TYPE ] = $this->type;
        }
        if ( in_array( WP_REST_Search_Controller::PROP_SUBTYPE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_SUBTYPE ] = $post->post_type;
        }
        return $data;
    }

    public function prepare_item_links( $id ) {
        $post = get_post( $id );
        $links = array();
        $item_route = rest_get_route_for_post( $post );
        if ( ! empty( $item_route ) ) {
            $links['self'] = array(
                'href' => rest_url( $item_route ),
                'embeddable' => true,
            );
        }
        $links['about'] = array(
            'href' => rest_url( 'wp/v2/types/' . $post->post_type ),
        );
        return $links;
    }
}

add_filter( 'wp_rest_search_handlers', function( $handlers ) {
    $handlers[] = new My_CPT_Search_Handler();
    return $handlers;
} );

Register this only when the default post handler genuinely cannot cover the object type. If you register it for a CPT that is already public and REST-exposed, you will get duplicate entries in search results.

Verification

Confirm the subtype appears in a live search response before and after the registration change. A minimal check:

curl -s 'https://example.com/wp-json/wp/v2/search?search=quarterly&subtype=your-cpt' | jq '.subtype'

Before the fix, expect an empty array or an error. After correcting the registration flags, expect the CPT slug to appear in the subtype values. If you added a custom handler, verify that the default post handler’s results are not duplicated.

For a broader diagnostic sequence on empty search results, see What to Fix First When a New WordPress Site Says Nothing Found. The same order applies here: check registration and status before touching the handler registry.

FAQ

Does the search endpoint search all post types by default?

No. The type parameter defaults to post, and the default post search handler’s subtype list is built from public, REST-exposed post types. A CPT that is not public or not exposed to REST will not appear.

Why does my CPT appear at its own REST route but not in search?

The collection route for a CPT is registered by WP_REST_Posts_Controller when the post type has show_in_rest true. The search endpoint uses a separate handler whose subtype list requires both public and show_in_rest to be true. A CPT with show_in_rest true but public false can have a working collection route and still be absent from search.

Can I add my CPT to search with the wp_rest_search_handlers filter?

You can, but you should not need to for a standard CPT. The default post handler already covers public, REST-exposed post types. Adding a handler for a CPT the default handler already covers will produce duplicate results. Use the filter for object types the post handler cannot represent.

What if my CPT entries are all drafts?

The default post search handler queries with post_status => 'publish'. Drafts, pending, and private entries will not appear. This is a status issue, not a registration issue. Publish at least one entry and retest before changing code.

Where is the wp_rest_search_handlers filter applied?

It is applied in create_initial_rest_routes(), which registers the default REST API routes. Each handler instance passed through the filter must extend WP_REST_Search_Handler.

What methods must a custom search handler implement?

WP_REST_Search_Handler declares search_items(), prepare_item(), and prepare_item_links() as abstract. search_items() must return an array with RESULT_IDS and RESULT_TOTAL keys.

Sources