WordPress Custom Post Types - A Developer's Guide

Custom post types extend WordPress beyond posts and pages. How to register them correctly, add custom fields, and query them efficiently.

Dobromir Dechev
Dobromir WordPress agency owner

Custom post types (CPTs) are one of WordPress's most powerful features and one of the most misunderstood. They let you create structured content types - team members, case studies, testimonials, events, products - beyond the default posts and pages.

This guide covers registering custom post types correctly, adding custom fields, setting up custom taxonomies, and querying CPTs efficiently.


What custom post types are (and are not)

A custom post type is a content type that shares WordPress's post infrastructure (the wp_posts table, the admin editing interface, the revision system) but is separate from standard posts.

WordPress itself uses this system internally: pages, attachments, and revisions are all post types. WooCommerce products are a custom post type.

Use CPTs when:

  • Content has structured, repeatable fields (team members with name, job title, photo, bio)
  • Content needs its own admin section separate from blog posts
  • Content is displayed in templates with custom layouts
  • Content should be queryable independently (portfolio items, events by date range)

Do not use CPTs when:

  • Simple categorisation handles the difference (a "tutorial" category for posts is fine)
  • The content is only displayed once (an about page is a page, not a CPT)
  • The content structure is so simple it adds no value over posts

Registering a custom post type

Use register_post_type() in a plugin or in your theme's functions.php. Plugin is better for CPTs because if you switch themes, the content type should not disappear.

add_action( 'init', function() {
    register_post_type( 'case_study', [
        'labels' => [
            'name'               => 'Case Studies',
            'singular_name'      => 'Case Study',
            'menu_name'          => 'Case Studies',
            'add_new'            => 'Add Case Study',
            'add_new_item'       => 'Add New Case Study',
            'edit_item'          => 'Edit Case Study',
            'new_item'           => 'New Case Study',
            'view_item'          => 'View Case Study',
            'search_items'       => 'Search Case Studies',
            'not_found'          => 'No case studies found',
            'not_found_in_trash' => 'No case studies in trash',
        ],
        'public'             => true,
        'publicly_queryable' => true,
        'show_ui'            => true,
        'show_in_menu'       => true,
        'show_in_rest'       => true,      // enables block editor
        'menu_position'      => 5,
        'menu_icon'          => 'dashicons-portfolio',
        'supports'           => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
        'has_archive'        => true,      // enables /case-studies/ archive
        'rewrite'            => [ 'slug' => 'case-studies' ],
        'capability_type'    => 'post',
    ]);
});

Key arguments explained

public: Makes the post type visible to users and searchable. Set to false for internal post types that should not have front-end URLs.

show_in_rest: Must be true to use the Gutenberg block editor with this post type. Also required for REST API access.

supports: Which features the post type supports. Options include title, editor, thumbnail, excerpt, revisions, custom-fields, comments, author.

has_archive: If true, enables a /case-studies/ archive page that lists all case studies. WordPress must have the archive template (archive-case_study.php) in the theme.

rewrite: The slug used in URLs. /case-studies/my-case-study/.

menu_icon: Dashicons class for the admin menu icon. Browse all options at developer.wordpress.org/resource/dashicons/.

Flush rewrite rules after registration

After registering a CPT, WordPress needs to flush its rewrite rules to generate the correct URL structure. This happens automatically on activation (if your CPT is in a plugin) or you can flush manually:

// In plugin activation hook
register_activation_hook( __FILE__, function() {
    // Register CPT first
    my_register_case_studies_cpt();
    flush_rewrite_rules();
});

Or visit Settings > Permalinks and click Save to flush manually.


Adding custom taxonomies

Custom taxonomies are category/tag systems for your custom post types. A case study might have an "Industry" taxonomy.

add_action( 'init', function() {
    register_taxonomy( 'industry', 'case_study', [
        'labels' => [
            'name'              => 'Industries',
            'singular_name'     => 'Industry',
            'menu_name'         => 'Industries',
            'all_items'         => 'All Industries',
            'edit_item'         => 'Edit Industry',
            'add_new_item'      => 'Add New Industry',
        ],
        'hierarchical'      => true,   // true = categories, false = tags
        'public'            => true,
        'show_ui'           => true,
        'show_in_rest'      => true,
        'rewrite'           => [ 'slug' => 'industry' ],
    ]);
});

Now you can filter case studies by industry at /industry/financial-services/.


Adding custom fields with ACF

The block editor handles text content. For structured data (job title, phone number, project URL, client logo), use ACF (Advanced Custom Fields).

Setting up an ACF field group

  1. Install ACF or ACF Pro
  2. ACF > Field Groups > Add New
  3. Name the group ("Case Study Details")
  4. Add fields: Text (client name), Image (client logo), URL (project URL), Textarea (results summary)
  5. Set the Location rule: "Post Type is equal to Case Study"
  6. Publish

These fields now appear in the case study editing screen below the content editor.

Outputting ACF fields in templates

In your single-case_study.php template:

<?php
$client_name  = get_field( 'client_name' );
$client_logo  = get_field( 'client_logo' );  // returns attachment array
$project_url  = get_field( 'project_url' );
$results      = get_field( 'results_summary' );
?>

<div class="case-study-header">
    <?php if ( $client_logo ): ?>
        <img src="<?php echo esc_url( $client_logo['url'] ); ?>"
             alt="<?php echo esc_attr( $client_logo['alt'] ); ?>"
             width="200" />
    <?php endif; ?>
    <h1><?php echo esc_html( $client_name ); ?></h1>
</div>

Querying custom post types

Use WP_Query to fetch CPT content:

$case_studies = new WP_Query([
    'post_type'      => 'case_study',
    'posts_per_page' => 6,
    'post_status'    => 'publish',
    'orderby'        => 'date',
    'order'          => 'DESC',
]);

if ( $case_studies->have_posts() ) {
    while ( $case_studies->have_posts() ) {
        $case_studies->the_post();
        // Template content
        get_template_part( 'template-parts/case-study-card' );
    }
    wp_reset_postdata();
}

Filter by taxonomy term

$financial_case_studies = new WP_Query([
    'post_type' => 'case_study',
    'tax_query' => [
        [
            'taxonomy' => 'industry',
            'field'    => 'slug',
            'terms'    => 'financial-services',
        ],
    ],
]);

Filter by custom field value

$recent_large_projects = new WP_Query([
    'post_type'  => 'case_study',
    'meta_query' => [
        [
            'key'     => 'project_value',
            'value'   => 50000,
            'compare' => '>=',
            'type'    => 'NUMERIC',
        ],
    ],
]);

Performance note: Queries on wp_postmeta (custom fields) are slower than queries on wp_posts columns. For high-traffic sites with large datasets, avoid meta_query for filtering on public-facing pages. Consider denormalising data into custom tables for frequently queried custom fields.


Custom templates for CPTs

WordPress template hierarchy for a CPT named case_study:

  • single-case_study.php - single case study view
  • archive-case_study.php - list of all case studies
  • taxonomy-industry.php - list filtered by industry taxonomy
  • taxonomy-industry-financial-services.php - specific term archive

Place these in your theme (or child theme) folder. WordPress automatically uses them when visiting the appropriate URL.


REST API access for CPTs

With 'show_in_rest' => true, your CPT is accessible via:

GET /wp-json/wp/v2/case_study
GET /wp-json/wp/v2/case_study/{id}

The REST API endpoint name is the post type key (case_study). To customise it:

'rest_base' => 'case-studies',

This changes the endpoint to /wp-json/wp/v2/case-studies/.

ACF fields are not included in the REST API by default. To include them, use ACF's REST API integration:

// In ACF field group settings, enable "Show in REST API"
// Or programmatically:
add_filter( 'acf/rest/get_fields', '__return_true' );

Was this article helpful?