WordPress ships with two content types: post and page. A blog post has a date, an author, categories and tags. A page is hierarchical and timeless. That covers a lot of use cases — but not all of them. A real estate listing has bedrooms, bathrooms, square footage, and a location. A job posting has a department, a salary range, and an application deadline. Storing those in a regular post works, but it’s awkward: you’re fighting the data model instead of using it.
Custom post types (CPTs) let you define content types that match your data exactly — with their own admin menu, their own archive URL, their own template hierarchy, and their own set of supported features. This guide covers the full registration flow: register_post_type(), custom taxonomies, custom fields, REST API exposure, and the trade-offs between code and a plugin like CPT UI.
When to use a custom post type
The decision isn’t “use a CPT or don’t” — it’s “what’s the right data model for this content?” Three questions that help:
- Does this content need its own URL structure? If portfolio items should live at
/portfolio/project-name/rather than/project-name/or a category archive, a CPT with its own rewrite slug is the right approach. - Does this content have attributes that don’t fit standard post meta? Categories and tags cover hierarchical and flat classification. If you need to store an address, a price, a date range, or a relationship to another post, you need custom fields alongside a CPT — or a dedicated plugin that owns its own tables.
- Should this content appear separately from blog posts in the admin? CPTs get their own admin menu entry, their own list table, and their own permission settings. If the content is managed by different people than the blog, separate admin areas reduce friction.
If the content is really just a differently-categorised blog post — same fields, same structure, just a different topic — a custom category is simpler and correct. CPTs add real complexity (template files, query logic, REST API considerations) that’s only worth it when the data genuinely warrants it.
Registering a custom post type
CPTs are registered via register_post_type(), hooked to init. The right place for this code is a plugin file (for portability) rather than functions.php — if the theme changes, a CPT in functions.php disappears along with it, which orphans the content.
function mytheme_register_portfolio_cpt() {
$labels = array(
'name' => 'Portfolio Items',
'singular_name' => 'Portfolio Item',
'add_new_item' => 'Add New Portfolio Item',
'edit_item' => 'Edit Portfolio Item',
'view_item' => 'View Portfolio Item',
'search_items' => 'Search Portfolio Items',
'not_found' => 'No portfolio items found.',
'not_found_in_trash' => 'No portfolio items found in trash.',
);
$args = array(
'labels' => $labels,
'public' => true,
'has_archive' => true,
'show_in_rest' => true,
'menu_icon' => 'dashicons-portfolio',
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ),
'rewrite' => array( 'slug' => 'portfolio' ),
'menu_position' => 5,
);
register_post_type( 'portfolio', $args );
}
add_action( 'init', 'mytheme_register_portfolio_cpt' );
Key arguments explained
public — controls whether the CPT is publicly queryable and visible in the admin. Setting this to true is equivalent to setting publicly_queryable, show_ui, show_in_nav_menus, and show_in_admin_bar all to true. For most CPTs you’ll want public => true; set it to false for internal data types that should never have a front-end URL.
has_archive — creates an archive page at the slug (e.g. /portfolio/). If false, no archive URL exists — only single post URLs. If you want the archive URL to use the same string as the rewrite slug, you can set has_archive => 'portfolio' explicitly; otherwise WordPress uses the post type slug.
show_in_rest — this is the one argument beginners most commonly miss. Setting it to true does two things: it exposes the CPT via the WP REST API at /wp-json/wp/v2/{post_type}, and it enables the Gutenberg block editor for this post type. If you leave this as false, the post edit screen will use the classic editor and the CPT won’t be accessible via the REST API — which matters if you’re building a headless frontend or using any REST-based integrations.
supports — the list of features the post type supports. The most commonly used values: title (required for most CPTs), editor (block editor body), thumbnail (featured image), excerpt, author, page-attributes (for hierarchical post types), revisions, and custom-fields (enables the custom fields meta box — needed if you’re using ACF without its own UI integration).
rewrite — controls the URL structure. 'slug' => 'portfolio' sets single post URLs to /portfolio/{post-name}/. If has_archive is true, the archive URL is /portfolio/. After registering a CPT with a new slug, you must flush rewrite rules — go to Settings > Permalinks and click Save, or call flush_rewrite_rules() once on activation. Not doing this is the most common reason a newly registered CPT returns 404s.
Adding a custom taxonomy
CPTs work with taxonomies the same way standard posts work with categories and tags. Register a custom taxonomy and attach it to your CPT with register_taxonomy():
function mytheme_register_portfolio_taxonomy() {
$labels = array(
'name' => 'Project Types',
'singular_name' => 'Project Type',
'search_items' => 'Search Project Types',
'all_items' => 'All Project Types',
'edit_item' => 'Edit Project Type',
'update_item' => 'Update Project Type',
'add_new_item' => 'Add New Project Type',
'new_item_name' => 'New Project Type Name',
'menu_name' => 'Project Types',
);
register_taxonomy( 'project_type', 'portfolio', array(
'labels' => $labels,
'hierarchical' => true, // true = category-like, false = tag-like
'show_in_rest' => true, // required for Gutenberg support
'rewrite' => array( 'slug' => 'project-type' ),
) );
}
add_action( 'init', 'mytheme_register_portfolio_taxonomy' );
The second argument to register_taxonomy() is the post type it’s attached to — it accepts a string or an array of post type slugs. Always register taxonomies on the same init hook as your CPT, and always set show_in_rest => true if you’re using Gutenberg.
Custom fields for your CPT
CPTs almost always need custom fields — additional data stored per post that doesn’t fit in the title, body, or taxonomy terms. You have three options:
WordPress core custom fields — the built-in meta box (enabled via 'custom-fields' in supports) uses add_post_meta() / update_post_meta() / get_post_meta(). No plugin required, but no UI beyond the basic key-value meta box. Fine for simple data; not practical for complex field structures.
ACF, Metabox, or Pods — the three main custom field plugins each provide a visual field group builder, more field types (image, relationship, repeater, gallery, map), and automatic REST API exposure of field values. ACF is the most widely used; Metabox has a stronger developer API; Pods includes its own CPT registration tools alongside custom fields. The detailed comparison of all three is in the ACF Pro vs. Metabox vs. Pods guide.
Custom database tables — for data that’s genuinely relational (many-to-many relationships, large datasets, complex queries), WordPress’s postmeta table doesn’t scale well. The alternative — registering a custom table and handling CRUD yourself — is covered in the guide to creating a custom database table in WordPress.
Querying custom post types
WP_Query queries CPTs the same way it queries posts, with one important caveat: custom post types are not included in the main query by default (unlike standard posts). You need to query them explicitly:
$portfolio_query = new WP_Query( array(
'post_type' => 'portfolio',
'posts_per_page' => 12,
'tax_query' => array(
array(
'taxonomy' => 'project_type',
'field' => 'slug',
'terms' => 'web-design',
),
),
'meta_query' => array(
array(
'key' => '_featured_project',
'value' => '1',
'compare' => '=',
),
),
) );
if ( $portfolio_query->have_posts() ) {
while ( $portfolio_query->have_posts() ) {
$portfolio_query->the_post();
// template output
}
wp_reset_postdata();
}
For complex queries combining custom fields and taxonomy terms — including the performance implications of meta_query on large datasets — see the guide to advanced WP_Query with custom fields and taxonomies.
REST API and Gutenberg
Setting show_in_rest => true registers the CPT with the REST API at /wp-json/wp/v2/{slug}. This does several things:
- Enables the Gutenberg block editor for that post type (required — without it, you’ll get the classic editor)
- Makes the CPT accessible for JavaScript-based frontends and headless WordPress setups
- Exposes taxonomy terms and post meta (if fields have
show_in_rest => truetoo) via the REST response
If you’re exposing custom fields via the REST API, register them individually via register_post_meta() with show_in_rest => true, or use ACF/Metabox’s built-in REST exposure settings. Fields stored without explicit REST registration won’t appear in the API response even if the CPT itself is exposed. The guide to WordPress custom REST API endpoints covers extending the REST API for custom post types in more detail.
CPT UI plugin vs. code
The Custom Post Type UI plugin provides a visual interface for registering CPTs and taxonomies without writing any PHP. It’s the right choice when:
- The client needs to register new CPTs themselves without developer involvement
- You’re prototyping and want to iterate quickly before committing to code
- The site is on a managed host where theme/plugin file edits are restricted
The trade-offs: CPT UI adds a plugin dependency that must be kept updated; the generated code runs on every request rather than only on init; and exporting to code (to move the registration out of the plugin) requires additional steps. For production sites where the CPT is a core part of the site structure, registering in code in a site-specific plugin gives you more control and eliminates the plugin dependency.
A practical pattern: use CPT UI to prototype the registration, then use its “Get PHP Code” export to generate the register_post_type() call and paste it into a site plugin.
Template files for your CPT
WordPress’s template hierarchy supports CPTs with two main template files:
single-{post_type}.php— template for individual CPT posts (e.g.single-portfolio.php)archive-{post_type}.php— template for the CPT archive (e.g.archive-portfolio.php)
If these files don’t exist in your theme, WordPress falls back to single.php and archive.php respectively. In a block theme (full site editing), the equivalent is a template file in the theme’s templates/ directory named single-portfolio.html. The guide to building a custom WordPress theme from scratch covers the template hierarchy and when to use each file.
Common mistakes
Forgetting to flush rewrite rules. After registering a new CPT with a custom slug, the rewrite rules don’t update until flushed. Single post and archive URLs return 404 until you visit Settings > Permalinks and click Save (or call flush_rewrite_rules() on a plugin activation hook). Don’t call it on every request — only on activation.
Registering CPTs in functions.php. If the theme changes, CPT registration disappears and all post type data becomes inaccessible from the admin. Register CPTs in a site-specific plugin (even a single-file mu-plugin) so they’re theme-independent.
Omitting show_in_rest => true. Without this, the Gutenberg editor won’t load for your CPT — it falls back to the classic editor. Any REST-based feature (Gutenberg, headless frontend, third-party integrations) requires this to be set.
Not setting has_archive => true when you want an archive. If you expect /portfolio/ to be a browsable page, you need has_archive. Without it, that URL returns 404.


