Custom Post Types: structuring content beyond posts and pages
WordPress natively offers two content types: posts and pages. Custom Post Types (CPTs) let you create additional content types tailored to the specific nature of a project's data. A portfolio site will have a "Project" type, a real estate site a "Property" type, an e-commerce site a "Product" type.
Each CPT has its own area in the WordPress back-office, its own taxonomies and its own custom fields. In headless architecture, CPTs are exposed via the REST API or WPGraphQL, allowing the frontend to consume them like any other resource.
Registering a headless-compatible Custom Post Type
A CPT is registered via the register_post_type() function in the theme's functions.php file or in a custom plugin. For a CPT to be accessible through the REST API, the show_in_rest parameter must be set to true.
// functions.php or custom plugin
add_action('init', function () {
register_post_type('project', [
'labels' => [
'name' => 'Projects',
'singular_name' => 'Project',
'add_new' => 'Add a project',
'add_new_item' => 'Add a new project',
'edit_item' => 'Edit project',
'view_item' => 'View project',
'all_items' => 'All projects',
'search_items' => 'Search projects',
'not_found' => 'No project found',
],
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-portfolio',
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'custom-fields'],
// Essential parameters for headless
'show_in_rest' => true, // Exposes the CPT in the REST API
'rest_base' => 'projects', // Customizes the REST endpoint slug
'show_in_graphql' => true, // Exposes the CPT in WPGraphQL
'graphql_single_name' => 'project', // Singular name for the GraphQL schema
'graphql_plural_name' => 'projects', // Plural name for the GraphQL schema
]);
});
show_in_rest is required
Without show_in_rest => true, the CPT does not appear in the REST API and the Gutenberg editor is not available for that content type. This parameter is essential in a headless architecture. For WPGraphQL, also add show_in_graphql => true.
Once registered, the CPT is accessible:
- Via REST:
GET /wp-json/wp/v2/projects - Via GraphQL:
query { projects { nodes { title slug } } }
Custom taxonomies
Taxonomies are used to classify content. WordPress provides categories and tags for posts. For CPTs, custom taxonomies are often necessary.
add_action('init', function () {
register_taxonomy('technology', ['project'], [
'labels' => [
'name' => 'Technologies',
'singular_name' => 'Technology',
'add_new_item' => 'Add a technology',
],
'hierarchical' => false, // false = tags, true = categories
'show_in_rest' => true,
'rest_base' => 'technologies',
'show_in_graphql' => true,
'graphql_single_name' => 'technology',
'graphql_plural_name' => 'technologies',
]);
register_taxonomy('project_type', ['project'], [
'labels' => [
'name' => 'Project types',
'singular_name' => 'Project type',
],
'hierarchical' => true,
'show_in_rest' => true,
'show_in_graphql' => true,
'graphql_single_name' => 'projectType',
'graphql_plural_name' => 'projectTypes',
]);
});
Advanced Custom Fields (ACF): enriching the content structure
ACF lets you add custom fields to any WordPress content type. In headless mode, these fields structure data precisely and in a typed way, making it easier for the frontend to consume.
Common ACF field types
Creating a field group for the Project CPT
In the WordPress back-office, go to ACF > Field Groups > Add. Create a "Project details" group with the following fields:
| Field name | Key | Type | Description |
|---|---|---|---|
| Client | client | Text | Client or company name |
| Year | year | Number | Year completed |
| Project URL | project_url | URL | Link to the live site |
| Technologies | technologies_used | Checkbox | List of technologies used |
| Detailed description | detailed_description | Wysiwyg | Long description of the project |
| Gallery | gallery | Gallery | Project screenshots |
| Client testimonial | testimonial | Group | Group containing text + name + position |
Assign this group to the "Project" content type via ACF's location rule.
Exposing ACF in the REST API
By default, ACF fields are not included in REST API responses. There are two methods to expose them.
Method 1: native ACF option (ACF PRO 5.11+)
In the ACF field group settings, enable "Show in REST API". The fields then appear inside an acf object in the JSON response.
{
"id": 42,
"title": { "rendered": "E-commerce site redesign" },
"slug": "ecommerce-site-redesign",
"acf": {
"client": "Fashion Boutique",
"year": 2024,
"project_url": "https://fashion-boutique.com",
"technologies_used": ["Next.js", "WordPress", "WooCommerce"],
"detailed_description": "<p>Full redesign of the site...</p>"
}
}
Method 2: manual exposure via register_rest_field
add_action('rest_api_init', function () {
register_rest_field('project', 'project_meta', [
'get_callback' => function ($post) {
return [
'client' => get_field('client', $post['id']),
'year' => get_field('year', $post['id']),
'project_url' => get_field('project_url', $post['id']),
'technologies' => get_field('technologies_used', $post['id']),
];
},
'schema' => [
'type' => 'object',
'description' => 'Project metadata',
],
]);
});
Exposing ACF in WPGraphQL
The WPGraphQL for ACF plugin automatically exposes ACF fields in the GraphQL schema. After installing and activating it, fields are immediately queryable.
query FullProject($slug: ID!) {
project(id: $slug, idType: SLUG) {
title
slug
featuredImage {
node {
sourceUrl
altText
}
}
projectDetails {
client
year
projectUrl
technologiesUsed
detailedDescription
gallery {
nodes {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
testimonial {
text
name
position
}
}
}
}
Naming ACF fields in GraphQL
WPGraphQL automatically converts ACF keys to camelCase. The project_url field becomes projectUrl, technologies_used becomes technologiesUsed. Use ACF keys in snake_case on the WordPress side to ensure readable GraphQL names.
Repeater and Flexible Content fields
Repeater: repeatable lists
The Repeater field creates a list of items sharing the same structure. Example: the steps of a project.
// Repeater field "steps" with subfields "step_title" and "step_description"
GraphQL query:
query {
project(id: "my-project", idType: SLUG) {
projectDetails {
steps {
stepTitle
stepDescription
}
}
}
}
Flexible Content: modular content
The Flexible Content field works like a page builder: it defines several block templates (layouts) the editor can assemble in any order. Each layout has its own subfields.
// Flexible Content "page_sections" with layouts:
// - "hero" (title, subtitle, background_image)
// - "text_image" (text, image, position)
// - "stats_grid" (statistics: repeater of value + label)
// - "testimonials" (testimonials: repeater of text + author)
On the frontend side, rendering uses a switch on the layout type:
function PageSections({ sections }) {
return sections.map((section, index) => {
switch (section.fieldGroupName) {
case 'Page_Sections_Hero':
return <HeroSection key={index} {...section} />;
case 'Page_Sections_TextImage':
return <TextImageSection key={index} {...section} />;
case 'Page_Sections_StatsGrid':
return <StatsGrid key={index} {...section} />;
case 'Page_Sections_Testimonials':
return <Testimonials key={index} {...section} />;
default:
return null;
}
});
}
Structuring best practices
Name fields in snake_case
Use explicit keys in snake_case (start_date, external_url, participant_count). These keys will be automatically converted to camelCase by WPGraphQL and remain readable in the REST API.
Return IDs for images
Configure ACF Image fields to return the ID (not the URL or array). This allows the frontend to retrieve all available sizes via the API and use the next/image component with the correct dimensions.
Document field groups
Add descriptions to every ACF field. These descriptions appear in the GraphQL schema and serve as documentation for frontend developers.
Separate content fields from configuration fields
Create distinct field groups for editorial content (text, images) and technical configuration (display options, layout settings). This clarifies the role of each field for editors.
Full example: Project CPT for a portfolio
On the Next.js side, TypeScript typing ensures consistency between WordPress data and the frontend:
interface Project {
title: string;
slug: string;
featuredImage: {
node: {
sourceUrl: string;
altText: string;
};
};
projectDetails: {
client: string;
year: number;
projectUrl: string;
technologiesUsed: string[];
detailedDescription: string;
gallery: {
nodes: Array<{
sourceUrl: string;
altText: string;
mediaDetails: { width: number; height: number };
}>;
};
};
technologies: {
nodes: Array<{ name: string; slug: string }>;
};
}