Custom Post Types in Headless WordPress: Beyond the Basics

Registering a custom post type in WordPress takes about 30 seconds. Making it work properly in a headless setup — with full REST API support, custom fields, taxonomies, and a clean data structure for your frontend — takes considerably more thought.
REST API Support From the Start
The most common mistake is registering a CPT without REST API support and wondering why it doesn’t show up in /wp-json/wp/v2/. Two arguments matter:
register_post_type('project', [
'labels' => [
'name' => 'Projects',
'singular_name' => 'Project',
],
'public' => true,
'show_in_rest' => true, // Required for REST API
'rest_base' => 'projects', // Custom endpoint slug
'rest_namespace' => 'wp/v2', // Or use a custom namespace
'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'custom-fields'],
'has_archive' => true,
'taxonomies' => ['category', 'project_type'],
]);
The 'custom-fields' support is important — without it, meta fields registered with register_meta() won’t appear in REST responses even if you set show_in_rest on the meta itself.
Custom Taxonomies for CPTs
Standard categories and tags work fine on CPTs, but custom taxonomies give you cleaner data structures. Register them with REST support too:
register_taxonomy('project_type', ['project'], [
'labels' => [
'name' => 'Project Types',
'singular_name' => 'Project Type',
],
'public' => true,
'hierarchical' => true,
'show_in_rest' => true,
'rest_base' => 'project-types',
]);
Now you can query projects by type: /wp-json/wp/v2/projects?project-types=5. The taxonomy terms themselves are available at /wp-json/wp/v2/project-types.
Adding Custom Fields to the REST Response
There are two approaches: register_meta() for simple values, and register_rest_field() for computed or complex data.
// Simple meta field — appears in response.meta
register_meta('post', 'project_url', [
'object_subtype' => 'project',
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'sanitize_callback' => 'esc_url_raw',
]);
// Computed field — appears as a top-level property
register_rest_field('project', 'tech_stack', [
'get_callback' => function ($post) {
$stack = get_post_meta($post['id'], 'tech_stack', true);
return $stack ? explode(',', $stack) : [];
},
'schema' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
]);
Working With ACF in the REST API
If you’re using Advanced Custom Fields, fields are exposed via the acf property in REST responses — but only if you enable it. In ACF’s field group settings, toggle “Show in REST API” on. For programmatic field groups:
acf_add_local_field_group([
'key' => 'group_project_details',
'title' => 'Project Details',
'fields' => [
[
'key' => 'field_project_client',
'label' => 'Client Name',
'name' => 'client_name',
'type' => 'text',
],
[
'key' => 'field_project_year',
'label' => 'Year',
'name' => 'project_year',
'type' => 'number',
],
],
'location' => [[
['param' => 'post_type', 'operator' => '==', 'value' => 'project'],
]],
'show_in_rest' => true,
]);
The response will include an acf object: { "acf": { "client_name": "Acme Corp", "project_year": 2026 } }.
Structuring Data for the Frontend
Raw WordPress REST responses are verbose. I always create a custom endpoint or use register_rest_field() to shape the data into what the frontend actually needs:
register_rest_route('mysite/v1', '/projects', [
'methods' => 'GET',
'callback' => function ($request) {
$query = new WP_Query([
'post_type' => 'project',
'posts_per_page' => $request->get_param('per_page') ?: 12,
'orderby' => 'menu_order',
'order' => 'ASC',
]);
$projects = array_map(function ($post) {
$thumb_id = get_post_thumbnail_id($post->ID);
return [
'id' => $post->ID,
'slug' => $post->post_name,
'title' => $post->post_title,
'excerpt' => get_the_excerpt($post),
'url' => get_post_meta($post->ID, 'project_url', true),
'image' => $thumb_id ? wp_get_attachment_image_url($thumb_id, 'large') : null,
'tech_stack' => wp_get_post_terms($post->ID, 'tech_stack', ['fields' => 'names']),
'type' => wp_get_post_terms($post->ID, 'project_type', ['fields' => 'names']),
];
}, $query->posts);
return new WP_REST_Response($projects);
},
'permission_callback' => '__return_true',
]);
This gives the frontend a flat, predictable structure instead of deeply nested WordPress objects with IDs that require additional lookups.
TypeScript Types on the Frontend
Match your frontend types to the API shape:
interface Project {
id: number;
slug: string;
title: string;
excerpt: string;
url: string | null;
image: string | null;
tech_stack: string[];
type: string[];
}
The key principle: WordPress is your content repository, not your frontend’s data model. Shape the API response to match what your components need, not what WordPress stores internally. Your frontend code should never have to parse WordPress’s nested object structure or make secondary API calls to resolve term IDs.
Written by
Adrian Saycon
A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.


