Understanding WordPress Custom Post Types: A Complete Guide


Custom Post Types Featured

WordPress custom post types are the mechanism that turns WordPress from a blogging tool into a real content platform. Posts and pages cover articles and static content, but a portfolio piece, a product, a team roster or a player profile has its own fields, its own archive and its own URL structure. This guide covers how custom post types work under the hood, how to register one correctly, and how to display it in a modern block theme.

What WordPress custom post types actually are

Every post type lives in the same place: the wp_posts table. The only thing that separates a page from a product is the value of the post_type column. WordPress ships with several built-in types you already use without noticing: post, page, attachment, revision, nav_menu_item, and since block themes arrived, wp_template, wp_template_part and wp_navigation.

Registering a custom type therefore does not create a new table. It tells WordPress how to treat rows with a new post_type value: which admin screens to show, which URLs to route, which editor features to enable, and whether the type is exposed to the REST API and the block editor. If you want to see how the rows are stored, the wp_posts and wp_postmeta tables are worth reading through column by column before you design a type.

That shared-table design is why WordPress custom post types scale well for editorial content. Revisions, featured images, authors, publish dates, scheduling, trash and search all work on day one, because they are features of the table, not of the type.

Registering a post type with register_post_type()

The registration function is register_post_type(), and it must run on the init hook. Calling it earlier fails because the rewrite and taxonomy systems are not ready; calling it later means the admin menu and rewrite rules never pick it up. Put the code in a plugin, not the theme, so the content survives a theme switch.

<?php
add_action( 'init', 'sf_register_player_post_type' );

function sf_register_player_post_type() {
    $labels = array(
        'name'          => __( 'Players', 'sf-roster' ),
        'singular_name' => __( 'Player', 'sf-roster' ),
        'add_new_item'  => __( 'Add New Player', 'sf-roster' ),
        'edit_item'     => __( 'Edit Player', 'sf-roster' ),
        'all_items'     => __( 'All Players', 'sf-roster' ),
    );

    register_post_type( 'sf_player', array(
        'labels'       => $labels,
        'public'       => true,
        'has_archive'  => 'players',
        'rewrite'      => array( 'slug' => 'player', 'with_front' => false ),
        'show_in_rest' => true,
        'menu_icon'    => 'dashicons-groups',
        'menu_position'=> 20,
        'supports'     => array( 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields', 'revisions' ),
        'taxonomies'   => array( 'sf_team' ),
        'template'     => array(
            array( 'core/post-featured-image', array( 'align' => 'wide' ) ),
            array( 'core/paragraph', array( 'placeholder' => 'Player biography' ) ),
        ),
    ) );
}

A few details in that example are deliberate. The post type key is prefixed and under 20 characters, which is a hard limit. show_in_rest is what unlocks the block editor; without it the type falls back to the classic editor. The template argument pre-fills new posts with a block layout, which is a small thing that editors appreciate every day.

Diagram showing how WordPress custom post types are registered with register_post_type() on the init hook

The arguments that matter most

register_post_type() accepts around forty arguments, but most projects only need to think about a handful. The table below lists the ones that change behaviour in ways you will notice.

ArgumentWhat it controlsTypical value
publicSets sensible defaults for visibility, queries, admin UI and searchtrue for content, false for internal data
show_in_restREST endpoint and block editor supporttrue
has_archiveWhether an archive URL exists, and its slugtrue or a string
hierarchicalParent/child relationships, page-stylefalse unless you need nesting
supportsEditor features: title, editor, thumbnail, excerpt, author, revisions, page-attributesArray of feature names
capability_typeWhich capabilities gate editing'post', or a custom pair with map_meta_cap
exclude_from_searchWhether items appear in site searchDefaults to the opposite of public

One trap: public => false plus show_ui => true is the correct combination for data that editors manage but visitors never open directly, such as a settings record or an import log. Setting public to true and then hiding the URLs with redirects is the wrong tool.

Displaying WordPress custom post types in a block theme

Block themes follow the same template hierarchy as classic themes, only the files are HTML. For a type called sf_player, WordPress looks for templates/single-sf_player.html and templates/archive-sf_player.html before falling back to single.html, archive.html and finally index.html. Our template hierarchy guide lists every fallback in order.

Inside a template the Query Loop block does the heavy lifting. Its postType attribute selects the type, and the inner Post Template block renders one item per result. This is a working archive loop you can paste into a template file:

<!-- wp:query {"queryId":7,"query":{"postType":"sf_player","perPage":12,"order":"asc","orderBy":"title","inherit":false}} -->
<div class="wp-block-query">
    <!-- wp:post-template {"layout":{"type":"grid","columnCount":3}} -->
        <!-- wp:post-featured-image {"isLink":true,"aspectRatio":"1"} /-->
        <!-- wp:post-title {"level":3,"isLink":true} /-->
        <!-- wp:post-terms {"term":"sf_team"} /-->
    <!-- /wp:post-template -->
    <!-- wp:query-pagination -->
        <!-- wp:query-pagination-previous /-->
        <!-- wp:query-pagination-numbers /-->
        <!-- wp:query-pagination-next /-->
    <!-- /wp:query-pagination -->
</div>
<!-- /wp:query -->

On the archive template itself you would normally set "inherit":true so the loop respects the URL, pagination and any filters applied by pre_get_posts. Set it to false only when you are embedding a secondary list on a page. Every item in that grid is a normal core block, so per-device spacing, typography and colour can be applied as block attributes with StepFox Looks rather than with theme CSS. The Gridiron football theme is built this way: teams, players and games are WordPress custom post types, and every roster grid is a Query Loop.

Example of WordPress custom post types displayed in a block theme with a Query Loop archive template

Adding taxonomies and structured meta

WordPress custom post types on their own are rarely enough. Players belong to teams, products belong to categories, case studies belong to industries. Register a taxonomy with register_taxonomy() on the same init hook and pass the post type in the second argument. The custom taxonomies guide covers hierarchical versus flat terms and the archive URLs each produces.

For scalar data such as a jersey number or a release date, register post meta explicitly. This gives the value a type, a sanitiser, REST exposure and a default, which means the block editor can bind to it and the REST API returns it in a predictable shape.

add_action( 'init', 'sf_register_player_meta' );

function sf_register_player_meta() {
    register_post_meta( 'sf_player', 'sf_jersey_number', array(
        'type'              => 'integer',
        'single'            => true,
        'default'           => 0,
        'show_in_rest'      => true,
        'sanitize_callback' => 'absint',
        'auth_callback'     => function () {
            return current_user_can( 'edit_posts' );
        },
    ) );
}

Once meta is registered with show_in_rest, a Paragraph or Image block can read it through the Block Bindings API, and a custom block can read it with useEntityProp without any extra endpoint work.

Rewrite rules and other common mistakes

The most frequent support ticket with WordPress custom post types is a 404 on every single item after registration. The cause is stale rewrite rules. WordPress caches its rewrite table in the rewrite_rules option and only regenerates it when told to. Flush once on plugin activation, never on every request:

register_activation_hook( __FILE__, function () {
    sf_register_player_post_type();
    flush_rewrite_rules();
} );

register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );

Other mistakes worth avoiding. Do not use reserved keys such as post, page, action, order or theme; the last three collide with query variables and produce confusing behaviour. Do not pick a rewrite slug that matches an existing page, because the page will silently win. And do not register the type inside a theme unless the type is purely presentational: if the content should outlive the design, it belongs in a plugin, which is the convention every StepFox theme follows.

Finally, remember that has_archive and hierarchical both change how wp_posts rows are queried. A hierarchical type with thousands of items will make the parent dropdown in the editor slow, so treat hierarchy as a feature you opt into for a reason, not a default.

Key takeaways

  • WordPress custom post types are rows in wp_posts with a different post_type value, so revisions, thumbnails and scheduling work immediately.
  • Register on init from a plugin, prefix the key, keep it under 20 characters and set show_in_rest to true for block editor support.
  • Block themes display a type through single-{type}.html and archive-{type}.html templates built around the Query Loop block.
  • Register taxonomies and post meta explicitly so the REST API, Block Bindings and custom blocks can read the data.
  • Flush rewrite rules once on activation, avoid reserved keys, and never let a rewrite slug collide with a page.

stephog Avatar

Share Article

Need a Custom Theme?

We create unique, high-performance WordPress themes tailored to your brand.

ABOUT US

© STEPFOX STUDIO 2020