Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 008 / 100

Custom post types tutorial: patterns, pitfalls and fixes

Model your own content types.

Expona Admin7 min read

Introduction

Custom post types are one of WordPress's most powerful features, yet many developers stumble through implementation, leaving themselves with messy registrations, missing REST API support, and rewrite rule headaches that cascade into production issues. This custom post types tutorial: patterns, pitfalls and fixes walks you through the right way to define, support, and maintain custom content types so your plugin or theme scales reliably alongside your business logic.

We'll cover registration patterns, which post features to enable, why REST API exposure matters, and the often-overlooked rewrite rule management that separates production-ready code from code that breaks after a plugin update.

Register — using register_post_type on init

The entry point for any custom post type is the register_post_type() function, and the critical rule is timing: you must call it on the init hook, never earlier in the WordPress lifecycle. Registering too early—such as in plugins_loaded or directly in your main plugin file—may seem to work during development but will fail when WordPress encounters edge cases like WP-CLI operations or certain caching scenarios.

Here's a solid foundation for a product post type:

php
add_action( 'init', function() {
    register_post_type( 'product', array(
        'label'       => 'Products',
        'public'      => true,
        'has_archive' => true,
        'supports'    => array( 'title', 'editor', 'thumbnail' ),
        'show_in_rest' => true,
    ) );
} );

The arguments passed here are your contract with WordPress. public means the post type appears in the admin menu and on the frontend. has_archive enables a products listing page. supports defines which editor features are available (more on this below). show_in_rest opens the post type to the REST API and the block editor.

In a real project, you'll want to move this into a class and make it reusable. Consider an approach that centralizes all post type registrations:

php
class PostTypeManager {
    public static function register_product() {
        register_post_type( 'product', array(
            'label'              => __( 'Products', 'textdomain' ),
            'public'             => true,
            'has_archive'        => true,
            'hierarchical'       => false,
            'supports'           => array( 'title', 'editor', 'thumbnail' ),
            'show_in_rest'       => true,
            'rest_base'          => 'products',
            'menu_icon'          => 'dashicons-cart',
        ) );
    }

    public static function register_all() {
        self::register_product();
    }
}

add_action( 'init', array( 'PostTypeManager', 'register_all' ) );

This pattern makes it easy to add multiple post types, test them in isolation, and avoid cluttering your main plugin file. The menu_icon argument uses WordPress dashicons to give your post type a recognizable admin sidebar icon, improving the user experience for content editors.

Supports — enabling the right editor features

The supports parameter is where many developers either add too much or too little. Each feature you include adds editor interface elements and stores data in predictable locations; each one you exclude saves clutter but may frustrate users later.

Common support flags include:

  • title — Post title field (almost always needed)
  • editor — Main content editor (block or classic)
  • thumbnail — Featured image
  • custom-fields — Post meta box in the classic editor
  • excerpt — Short description field
  • author — Author selection dropdown
  • revisions — Post revision tracking
  • comments — Comment threading

In a typical e-commerce scenario, a product post type might look like this:

php
register_post_type( 'product', array(
    'label'    => 'Products',
    'public'   => true,
    'supports' => array(
        'title',
        'editor',
        'thumbnail',
        'excerpt',
        'revisions',
        'custom-fields'
    ),
    'show_in_rest' => true,
) );

Here, we enable revisions so marketing teams can revert product descriptions if needed. excerpt gives us a short teaser for product listings. custom-fields allows developers to attach meta via the classic editor's custom fields panel—though in production, you'll likely register proper meta fields (more on this in a later article).

Do not enable comments for product post types unless you genuinely need them. Extra features create extra database queries and maintenance burden. Be intentional.

show_in_rest — unlocking the block editor and REST API

If you set show_in_rest to false or omit it entirely, your post type will work in the classic editor but will fail silently in the block editor. Worse, external tools and mobile apps trying to consume your content via the REST API will get a 404.

This single argument has caught many developers off guard in production. A site that works perfectly in the classic editor suddenly appears broken when the client upgrades WordPress to use blocks, or when your mobile app team tries to integrate.

Always set this to true for public post types:

php
register_post_type( 'product', array(
    'label'           => 'Products',
    'public'          => true,
    'show_in_rest'    => true,
    'rest_base'       => 'products',  // Customize the endpoint
    'rest_controller_class' => 'WP_REST_Posts_Controller',
) );

The rest_base argument lets you customize the API endpoint. Without it, WordPress uses the post type name. With it, you can expose /wp-json/wp/v2/products instead of /wp-json/wp/v2/product, which reads more naturally.

If you need to control REST access more granularly—for example, only authenticated users can view drafts—you register custom post meta fields separately and declare which roles can read or edit them. That's a deeper topic, but the key point is: REST support opens your post type to the modern WordPress ecosystem.

Rewrite slugs — flush rewrite rules on activation, not every request

When you register a post type with a custom slug, WordPress generates rewrite rules that map URLs to post IDs. The first time a post type is registered, these rules must be flushed (cleared and regenerated) or your custom URLs will return 404.

Many developers add rewrite flushing to the init hook or even in the register_post_type call itself. This is a performance disaster. Flushing rewrite rules rewrites the `.htaccess` file (or equivalent), which is expensive. Doing it on every request tanks performance.

The correct pattern is to flush rewrite rules once, on plugin activation:

php
// In your main plugin file
register_activation_hook( __FILE__, array( 'MyPlugin', 'activate' ) );

class MyPlugin {
    public static function activate() {
        // Register the post type first
        self::register_post_types();
        // Then flush rewrite rules
        flush_rewrite_rules();
    }

    public static function register_post_types() {
        register_post_type( 'product', array(
            'label'       => 'Products',
            'public'      => true,
            'has_archive' => true,
            'rewrite'     => array( 'slug' => 'products' ),
            'show_in_rest' => true,
        ) );
    }
}

add_action( 'init', array( 'MyPlugin', 'register_post_types' ) );

When the plugin activates, WordPress runs the activation hook, which registers the post type and flushes rules once. From then on, every normal request uses the cached rules. If you later change the slug, you need to manually trigger another flush via plugin re-activation or a custom admin button.

An alternative pattern, useful during development or for plugins with frequently changing structures, is to check if a transient exists and flush only if it doesn't:

php
add_action( 'init', function() {
    register_post_type( 'product', array(
        'label'       => 'Products',
        'public'      => true,
        'rewrite'     => array( 'slug' => 'products' ),
        'show_in_rest' => true,
    ) );

    if ( false === get_transient( 'my_plugin_flushed_rewrite_rules' ) ) {
        flush_rewrite_rules();
        set_transient( 'my_plugin_flushed_rewrite_rules', 1, 3600 );
    }
} );

This flushes rewrite rules at most once per hour, balancing safety with performance. In production, prefer the activation hook approach; it's cleaner and more predictable.

Common mistakes

  • Registering outside the init hook: Causes intermittent failures and breaks WP-CLI. Always use add_action( 'init', ... ).
  • Forgetting show_in_rest: Your post type works in the classic editor but breaks in the block editor and REST clients. Always set it to true for public types.
  • Flushing rewrite rules on every request: This tanks performance. Flush on activation only, or use a transient as a fallback.
  • Over-enabling supports: Adding every feature clutters the editor. Be selective; you can always add meta fields and custom controls later.
  • Using inconsistent slugs: If your rewrite slug doesn't match your REST base, API consumers get confused. Keep them aligned or explicitly set both.
  • Not translating strings: Label and description should always use __( 'text', 'textdomain' ) for i18n. Don't hardcode English.

Key takeaways

  • Always register post types on the init hook; any earlier and you risk silent failures in edge cases.
  • Set show_in_rest to true for any public post type; it's required for block editor and REST API support.
  • Choose the supports argument deliberately—only enable features your post type and users actually need.
  • Flush rewrite rules once on activation, never on every request; use a transient as a safety fallback during development.
  • Use meaningful rest_base and slug values that clearly indicate the content type to both humans and machines.
  • Organize post type registrations in a class or dedicated file; it scales better than scattered registrations in your main plugin.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles