Back

Creating a Custom Post Type in WordPress

Creating a Custom Post Type in WordPress

WordPress ships with Posts and Pages, but those two content types won’t carry you far when a client needs a property listings directory, a staff roster, or a recipe archive. That’s where a WordPress custom post type comes in — a first-class content structure that fits cleanly into WordPress’s existing data model without hacking around the defaults.

Key Takeaways

  • A custom post type (CPT) extends the shared wp_posts table rather than creating a separate one, giving you admin screens, REST API endpoints, and template routing out of the box.
  • Always register CPTs in a plugin, not a theme, so content remains accessible regardless of theme changes.
  • Setting show_in_rest to true is required for block editor support and REST API exposure.
  • Flush rewrite rules only on plugin activation — never on every page load.

What Is a Custom Post Type?

A custom post type (CPT) is a named content type you register with WordPress. Despite the name, it isn’t a separate database table. All post types — built-in and custom — share the wp_posts table, differentiated by the post_type column. That’s an important mental model: you’re extending an existing structure, not creating a parallel one.

Default post types include post, page, attachment, revision, and nav_menu_item. When you register a CPT like property or recipe, WordPress treats it with the same consistency: admin screens, REST API endpoints, URL routing, and template hierarchy all work the same way.

Custom post types ≠ custom fields. A CPT defines the type of content. Custom fields (added via ACF or the metadata API) store extra data attached to that content. These are separate concerns.

Registering a Custom Post Type with register_post_type()

The canonical way to create a WordPress custom post type is the register_post_type() function, called on the init hook.

Here’s a clean, working example:

add_action( 'init', 'register_property_post_type' );

function register_property_post_type() {
    $labels = [
        'name'               => 'Properties',
        'singular_name'      => 'Property',
        'add_new_item'       => 'Add New Property',
        'edit_item'          => 'Edit Property',
        'not_found'          => 'No properties found.',
    ];

    $args = [
        'labels'       => $labels,
        'public'       => true,
        'has_archive'  => true,
        'supports'     => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
        'rewrite'      => [ 'slug' => 'properties' ],
        'show_in_rest' => true,
    ];

    register_post_type( 'property', $args );
}

Key Arguments Explained

  • public — Makes the CPT visible in the admin and on the front end. Set to false for internal-only types.
  • labels — Controls all the UI strings in the admin. Worth filling out properly so editors aren’t confused.
  • supports — Declares which editor features appear on the edit screen. Common values: title, editor, thumbnail, excerpt, custom-fields.
  • has_archive — Enables an archive page at /properties/. WordPress will look for a matching archive-property.php template (or fall back to archive.php).
  • rewrite — Sets the URL slug. Single posts will appear at /properties/post-name/.
  • show_in_restSet this to true. This exposes the CPT via the WordPress REST API and is required for the block editor to work correctly with your post type. Without it, the editor falls back to the classic interface.

Plugin vs. Theme: Where Should the Code Live?

This is a common point of confusion. Register your custom post type in a plugin, not a theme.

If your CPT registration lives in functions.php and the client switches themes, all content of that type becomes inaccessible — not deleted, but invisible. A dedicated plugin (even a simple one-file plugin) keeps the CPT active regardless of theme changes.

Don’t flush rewrite rules on every request. Call flush_rewrite_rules() only once — on plugin activation via register_activation_hook(). Flushing on every init is a performance problem.

Template Hierarchy for Custom Post Types

WordPress looks for templates in this order for a single CPT entry:

  1. single-{post-type}.php (e.g., single-property.php)
  2. single.php
  3. singular.php
  4. index.php

For archives, it follows: archive-{post-type}.phparchive.phpindex.php.

Conclusion

With register_post_type() correctly hooked to init, show_in_rest enabled, and the registration living in a plugin, you have a solid, portable foundation. From here you can layer in custom taxonomies, meta fields via ACF or the metadata API, and custom admin columns — without touching anything that would break when WordPress or your theme updates.

FAQs

The content stays in the wp_posts table and is not deleted. However, WordPress no longer recognizes that post type, so the posts become invisible in the admin and on the front end. Reactivating the plugin restores access immediately. This is why CPT registration should always live in a dedicated plugin rather than a theme.

Yes. You can call register_post_type() multiple times within the same init callback or use separate callbacks hooked to init. There is no limit to the number of custom post types a single plugin can register, though each must have a unique post type slug no longer than 20 characters.

This almost always means the rewrite rules need to be flushed. Visit Settings then Permalinks in the WordPress admin and click Save Changes. That triggers a rewrite flush. For plugins, call flush_rewrite_rules() inside register_activation_hook() so it runs automatically on activation. Never flush on every page load.

Not strictly, but it is still recommended. Setting show_in_rest to true exposes your post type through the WordPress REST API, which is useful for headless setups, external integrations, and future compatibility. If you ever switch to the block editor, it will already work correctly.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay