This is how we want to build REST APIs for our headless frontends going forward. It came out of comparing two real approaches in our own codebases, so the examples are concrete.
Approach A, the cfr-api way: global rest_prepare_{post_type} filters.
cfr-api enriches the built-in wp/v2 responses by hooking rest_prepare_{post_type}
in its Models (see PostObject.php). ACF fields, taxonomy terms, recirc data, parsed
blocks all get added to the standard WP response.
Approach B, the gct-api way: a dedicated gct/v2 namespace.
All the response shaping, calculations, map data formatting, taxonomy extraction happens
only in gct/v2. The built-in wp/v2 is left alone.
The rest_prepare_{post_type} filter is the WP-sanctioned hook for this, so using it is
not going against WordPress. The problem is what it does to the contract.
When you hook that filter you rewrite the wp/v2 shape. So the promise that "a post on
wp/v2/posts is standard WordPress shape" becomes a lie. That has a few real costs:
- Any other consumer (a plugin, an integration, the OpenAPI generator, a future dev) gets a non-standard payload where they expected the standard one.
- You can't version the shape on your own.
wp/v2is WordPress's version, not yours. When you need a breaking change there's nowhere to go. - The frontend ends up using both
cfr/v1andwp/v2prefixes, and thewp/v2one is already modified under the hood. That mix is confusing.
There's a tempting upside to the filter: it runs everywhere. If you set an author on a post through an ACF field, the filter recomputes that author too, you don't have to wire it into the post model by hand.
But that same "runs everywhere" is also the downside. If you attach a costly calculation to that author, then:
- you now run the costly calculation everywhere, on every endpoint that returns the
post (the
contextparam helps fence some of this off), and - you have to fence it off by hand anyway.
So the convenience and the trap are the same property.
A gct/v2 namespace makes sense, but only with two rules:
- The frontend talks only to
gct/v2.wp/v2stays for the admin. If the frontend still hitswp/v2for a couple of things, you've rebuilt the same two-prefix mess, just without the filters' convenience. - You build it on
WP_REST_Controllersubclasses, not raw route logic. That way the API is built on top of WordPress: the query, pagination, and schema come for free.
The mental model: you install an API plugin that gives you a new path where it does its own work, but it doesn't poke into existing stuff. The base stays the base.
gct-api's PostsController extends WP_REST_Posts_Controller, binds it to gct/v2,
and exposes GET only:
class PostsController extends WP_REST_Posts_Controller {
const API_NAMESPACE = 'gct/v2';
public function __construct( string $post_type ) {
parent::__construct( $post_type );
$this->namespace = self::API_NAMESPACE;
}
public function register_routes(): void {
// GET collection + GET single only.
// callbacks point at the inherited get_items / get_item
// args come from $this->get_collection_params()
// schema comes from $this->get_public_item_schema()
}
public function prepare_item_for_response( $item, $request ): WP_REST_Response {
$response = parent::prepare_item_for_response( $item, $request );
if ( $item instanceof WP_Post ) {
$response = Registry::enrich( $response, $item, $request );
}
return $response;
}
}Because the controller subclasses WP_REST_Posts_Controller, you inherit everything:
pagination, the X-WP-Total headers, filtering, ordering, _embed, get_collection_params,
the schema, the permission checks. You don't rewrite WP_Query by hand. That was the
whole worry about leaving the filters behind ("I'm skipping WP's benefits"), and this is
exactly how you don't skip them.
Because it's registered only under gct/v2, wp/v2/* stays byte-for-byte unchanged. The
enrichment lives in the overridden prepare_item_for_response, so it's scoped to this
namespace and doesn't leak anywhere.
Yes, and that's fine. register_rest_route is the only API to register a route in WP.
Core's own WP_REST_Posts_Controller::register_routes() calls it too. So its presence
means nothing on its own, every REST route goes through it, wp/v2 included.
The raw route smell is not the registration, it's the callback content. The bad version would be:
'callback' => function ( $request ) {
$q = new WP_Query( /* hand-built args */ );
// manual pagination, manual _fields, manual filtering, manual schema...
}In the good version the callback points at the inherited get_items / get_item, the
args come from the inherited get_collection_params(), and the schema comes from
get_public_item_schema(). So register_rest_route here is just wiring, all the muscle
stays the parent's.
And that's also why you override register_routes() instead of calling
parent::register_routes(): the parent would register the write routes (POST/PUT/DELETE)
too, and we want GET only. So you register by hand, but pointing at the parent's methods.
That's the right way to narrow it to read-only.
The real distinction is not "register_rest_route or not", it's "does the route use the parent's logic, or do you rewrite it by hand". Use the parent's.
Schema drift. The route returns the parent's get_public_item_schema, but
enrich() adds fields that aren't in that schema. So the schema doesn't describe your
extra fields, and if you generate frontend types from it, those fields won't show up.
This is the same drift we hit with PostObject::schema() in cfr-api. If you want a typed
contract, override get_item_schema() and merge your added fields in, otherwise the
schema lies.
Return type compatibility. Adding : WP_REST_Response on prepare_item_for_response
where the core parent has no declared return type is legal PHP (the child can add a type
when the parent has none). prepare_item_for_response does always return a
WP_REST_Response (the WP_Error cases come from get_item / permission checks, not
here), so the type is accurate.
Auth gating. If the frontend sits behind a REST auth plugin (like
cfr-rest-api-authentication on cfr-education, which gates all REST GET requests with
path exclusions), check that gct/v2 is gated the way you want. That's not the
controller's job, but it's easy to forget.