14+ years building on WordPress / Replies in under 5 hours
Development 9 min read · Updated July 2026

WordPress Custom Post Types: Complete Guide with Code

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Mastering Custom Post Types in WordPress: A Beginner’s Guide
TL;DR

A custom post type (CPT) is a structured content type registered in WordPress alongside the built-in `post` and `page` types — used for content with attributes that don't fit the standard model: portfolio items, properties, events, testimonials, job listings. CPTs are registered using `register_post_type()` hooked to `init`, ideally in a site-specific plugin rather than `functions.php`. Setting `show_in_rest: true` is required for Gutenberg editor support and REST API access. After registering a CPT with a new slug, flush rewrite rules by visiting Settings > Permalinks — skipping this causes 404s on all CPT URLs. Custom fields are typically added via ACF, Metabox, or Pods; core postmeta works but has no visual UI beyond the basic key-value box.

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:

  1. 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.
  2. 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.
  3. 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 => true too) 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.

Frequently asked questions

A custom post type (CPT) is a content type registered in WordPress alongside the built-in 'post' and 'page' types. Examples: portfolio items, events, properties, job listings, testimonials. Each CPT gets its own admin menu entry, its own URL structure (e.g. /portfolio/project-name/), its own archive page (e.g. /portfolio/), and its own template files in the theme. CPTs are registered with register_post_type() and can be extended with custom taxonomies (for classification) and custom fields (for additional data per item).

Add a function to a site-specific plugin file (or functions.php) that calls register_post_type() and hooks it to 'init'. The minimum working registration needs: a post type slug (the first argument), 'labels' (the admin UI text), 'public' => true (makes it queryable and visible in the admin), and 'show_in_rest' => true (enables the Gutenberg editor and REST API). After registering with a new slug, visit Settings > Permalinks in wp-admin and click Save to flush rewrite rules — without this, single post and archive URLs return 404.

A custom post type defines a new kind of content (a 'portfolio item' is a different thing from a 'blog post'). A custom taxonomy defines a classification system for grouping that content (portfolio items can be classified by 'project type'). The built-in equivalent: 'post' is the post type; 'category' and 'post_tag' are its taxonomies. Custom post types and custom taxonomies are registered separately and linked — you specify which post types a taxonomy applies to in the register_taxonomy() call. Both are entirely independent of custom fields, which store per-item data rather than grouping content.

Setting 'show_in_rest' => true in register_post_type() does two things: it registers the post type with the WordPress REST API at /wp-json/wp/v2/{slug}, making it accessible to REST-based integrations, headless frontends, and external tools; and it enables the Gutenberg block editor for that post type. Without it, Gutenberg falls back to the classic editor for your CPT, and the post type won't appear in REST API responses. Custom taxonomies attached to the CPT also need 'show_in_rest' => true set separately in register_taxonomy().

No. Custom post types and ACF (Advanced Custom Fields) are independent features. register_post_type() registers the post type; ACF adds a visual field group builder for storing additional per-post data (addresses, prices, file uploads, relationships). You can use a CPT without ACF if the built-in post fields (title, editor, thumbnail, excerpt) are sufficient. Most real-world CPTs pair with ACF, Metabox, or Pods because they need structured custom data that the default post fields don't cover — but the CPT registration itself doesn't require any plugin.

Code (in a site-specific plugin) is the right choice for production sites where the CPT is a permanent part of the site structure — it eliminates the plugin dependency, runs with slightly less overhead, and survives plugin updates without risk of settings changing. CPT UI is the right choice when the client needs to register CPTs themselves without developer access, when you're prototyping quickly, or when the site is on a restricted host. A practical middle path: use CPT UI to build the registration visually, then export its 'Get PHP Code' output and paste it into a plugin file to remove the dependency.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →