Skip to content

Instantly share code, notes, and snippets.

@Zmetser
Created June 10, 2026 12:06
Show Gist options
  • Select an option

  • Save Zmetser/825a057d6755d2c2f419e56f9413efdf to your computer and use it in GitHub Desktop.

Select an option

Save Zmetser/825a057d6755d2c2f419e56f9413efdf to your computer and use it in GitHub Desktop.

Building APIs for a headless frontend: namespaced controllers, not global filters

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.

The two approaches

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.

Why the filter approach gets muddy

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/v2 is WordPress's version, not yours. When you need a breaking change there's nowhere to go.
  • The frontend ends up using both cfr/v1 and wp/v2 prefixes, and the wp/v2 one 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:

  1. you now run the costly calculation everywhere, on every endpoint that returns the post (the context param helps fence some of this off), and
  2. you have to fence it off by hand anyway.

So the convenience and the trap are the same property.

The decision

A gct/v2 namespace makes sense, but only with two rules:

  1. The frontend talks only to gct/v2. wp/v2 stays for the admin. If the frontend still hits wp/v2 for a couple of things, you've rebuilt the same two-prefix mess, just without the filters' convenience.
  2. You build it on WP_REST_Controller subclasses, 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.

How it looks in code

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.

"But it uses register_rest_route"

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.

Caveats to watch

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment