Type something to search...
How to Create Custom Post Types in WordPress?

How to Create Custom Post Types in WordPress?

Custom post types let you model content that isn't a post or page, like Products, Testimonials, or Case Studies, as its own first-class content type with its own admin UI, its own URL structure, and its own editing screen. Instead of forcing every kind of content through the built-in "Post" type and telling categories or tags apart to fake a distinction, you register a type that fits the content itself.

WordPress ships with two post types out of the box, post and page, but under the hood every attachment, nav menu item, and revision is also a post type, just one that mostly stays invisible to editors. register_post_type() is the same API that powers all of them, and once you understand its arguments, adding a "Product" or "Testimonial" type that behaves exactly like a native one takes a single function call.

This guide walks through registering a full custom post type with a complete $args array, understanding what each argument actually controls, adding support for custom capabilities, exposing it to the REST API and block editor, and the handful of mistakes that cause a type to silently misbehave.

Where to Put This Code

Register custom post types from a plugin, not a theme's functions.php, if there's any chance the content should outlive a theme switch. A products.php file in a small custom plugin (or a must-use plugin) means your Product posts stay intact even if the site gets a new theme next year. Everything below works identically in either location; only the survivability differs.

Step 1: Register a Basic Custom Post Type

register_post_type() takes two arguments: the post type's slug (20 characters or fewer, lowercase, no spaces) and an array of arguments. It must run on the init hook:

add_action( 'init', function () {
    register_post_type( 'product', [
        'labels' => [
            'name'          => 'Products',
            'singular_name' => 'Product',
        ],
        'public' => true,
    ] );
} );

That's a working custom post type: "Products" appears as its own menu item in the admin sidebar, with an editor screen and a public archive at /product/ and single pages at /product/example-product/. It's minimal, but almost everything worth doing lives in the arguments you didn't set yet.

Step 2: Register a Complete Post Type

A real-world "Product" post type needs full labels (so every screen in wp-admin reads correctly instead of falling back to generic post language), explicit supports, an icon, and REST API exposure. Here's a complete, production-shaped registration:

add_action( 'init', function () {
    $labels = [
        'name'                  => 'Products',
        'singular_name'         => 'Product',
        'menu_name'             => 'Products',
        'name_admin_bar'        => 'Product',
        'add_new'               => 'Add New',
        'add_new_item'          => 'Add New Product',
        'new_item'              => 'New Product',
        'edit_item'             => 'Edit Product',
        'view_item'             => 'View Product',
        'view_items'            => 'View Products',
        'all_items'             => 'All Products',
        'search_items'          => 'Search Products',
        'not_found'             => 'No products found.',
        'not_found_in_trash'    => 'No products found in Trash.',
        'featured_image'        => 'Product Image',
        'set_featured_image'    => 'Set product image',
        'remove_featured_image' => 'Remove product image',
        'use_featured_image'    => 'Use as product image',
        'archives'              => 'Product Archives',
        'insert_into_item'      => 'Insert into product',
        'uploaded_to_this_item' => 'Uploaded to this product',
        'filter_items_list'     => 'Filter products list',
        'items_list_navigation' => 'Products list navigation',
        'items_list'            => 'Products list',
    ];

    $args = [
        'labels'              => $labels,
        'description'         => 'Product catalog entries.',
        'public'              => true,
        'publicly_queryable'  => true,
        'show_ui'             => true,
        'show_in_menu'        => true,
        'show_in_nav_menus'   => true,
        'show_in_admin_bar'   => true,
        'show_in_rest'        => true,
        'rest_base'           => 'products',
        'menu_position'       => 5,
        'menu_icon'           => 'dashicons-cart',
        'capability_type'     => 'post',
        'hierarchical'        => false,
        'supports'            => [ 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields', 'revisions' ],
        'has_archive'         => true,
        'exclude_from_search' => false,
        'query_var'           => true,
        'can_export'          => true,
        'rewrite'             => [
            'slug'       => 'products',
            'with_front' => false,
        ],
    ];

    register_post_type( 'product', $args );
} );

The arguments that matter most in practice:

  • labels covers every string wp-admin displays for this post type. Skipping most of them isn't fatal, they'll fall back to generic "Post" language, but a client-facing site reads noticeably more polished with the full set filled in.
  • supports controls which editor boxes appear. Leaving out 'editor' removes the content field entirely, which is intentional for post types that are pure metadata containers (see the Testimonial example below). 'custom-fields' enables the classic custom fields meta box, separate from whether ACF is installed.
  • menu_icon accepts any Dashicons class name (dashicons-cart, dashicons-star-filled, and so on), or a full URL/base64 data URI to a custom SVG.
  • show_in_rest: true is what makes this post type available to the block editor at all, and to the WordPress REST API at /wp-json/wp/v2/products. Without it, the post type only gets the classic editor, and headless/JS-driven front ends can't reach it through core's REST endpoints.
  • has_archive: true generates an automatic archive page at /products/ (controlled separately by rewrite.slug) listing all published products, using your active theme's archive.php or archive-product.php template if one exists.
  • rewrite.slug decouples the URL segment from the post type's internal name. Here, the post type is product (singular, used in code) but URLs use products (plural, more natural for an archive listing).

Step 3: A Non-Public, Metadata-Only Post Type

Not every custom post type needs its own front-end page. A "Testimonial" type used purely as content pulled into other pages via a widget or shortcode is a good candidate for 'public' => false with a couple of exceptions carved back out:

add_action( 'init', function () {
    register_post_type( 'testimonial', [
        'labels' => [
            'name'          => 'Testimonials',
            'singular_name' => 'Testimonial',
            'add_new_item'  => 'Add New Testimonial',
            'edit_item'     => 'Edit Testimonial',
            'all_items'     => 'All Testimonials',
        ],
        'public'             => false,
        'show_ui'            => true,
        'show_in_menu'       => true,
        'show_in_rest'       => true,
        'menu_icon'          => 'dashicons-format-quote',
        'supports'           => [ 'title', 'editor', 'custom-fields' ],
        'has_archive'        => false,
        'exclude_from_search' => true,
        'publicly_queryable' => false,
        'rewrite'            => false,
    ] );
} );

public: false turns off the front-end archive, single template, and search visibility all at once, but show_ui and show_in_menu keep it fully editable in wp-admin. This is the right shape for content that's always queried programmatically (with WP_Query or get_posts()) and displayed inside another template or shortcode, like a "Testimonials" shortcode that pulls three random entries into a homepage section, rather than content visitors ever browse to directly.

Step 4: Query Your Custom Post Type

Once registered, a custom post type works with the same query tools as any other content:

$products = new WP_Query( [
    'post_type'      => 'product',
    'posts_per_page' => 12,
    'orderby'        => 'title',
    'order'          => 'ASC',
] );

if ( $products->have_posts() ) :
    while ( $products->have_posts() ) : $products->the_post();
        ?>
        <article <?php post_class(); ?>>
            <h2><?php the_title(); ?></h2>
            <?php the_post_thumbnail( 'medium' ); ?>
            <?php the_excerpt(); ?>
        </article>
        <?php
    endwhile;
    wp_reset_postdata();
endif;

wp_reset_postdata() after a custom WP_Query loop is easy to forget and causes real bugs: without it, the global $post object stays pointed at the last product in your loop, and any the_title() or the_content() call later in the same template (in a sidebar, for instance) silently shows product data instead of the actual page content.

Step 5: Add Custom Template Files

WordPress resolves templates for custom post types the same way it does for regular posts, following the template hierarchy, just using the post type's slug:

  • single-product.php — a single product's page (falls back to single.php, then index.php).
  • archive-product.php — the /products/ archive listing (falls back to archive.php).

A minimal single-product.php:

<?php get_header(); ?>

<main class="site-main">
    <?php while ( have_posts() ) : the_post(); ?>
        <article <?php post_class(); ?>>
            <h1><?php the_title(); ?></h1>
            <?php the_post_thumbnail( 'large' ); ?>
            <div class="product-description">
                <?php the_content(); ?>
            </div>
            <?php
            $price = get_post_meta( get_the_ID(), 'price', true );
            if ( $price ) :
            ?>
                <p class="product-price"><?php echo esc_html( $price ); ?></p>
            <?php endif; ?>
        </article>
    <?php endwhile; ?>
</main>

<?php get_footer(); ?>

If your theme doesn't have single-product.php or archive-product.php, WordPress falls back to the generic single.php/archive.php, which will render the product but without any product-specific markup, so it's worth adding these template files as soon as the type needs to look different from a regular post.

Step 6: Handle Flush Rewrite Rules Correctly

The single most common "my custom post type's pages all 404" bug has nothing to do with register_post_type() itself: WordPress builds its rewrite rules (the internal map from pretty URLs to query parameters) once and caches them, so a newly registered post type's URL pattern doesn't exist yet until that cache is rebuilt.

Never call flush_rewrite_rules() on every page load — it's an expensive operation that rewrites the rewrite_rules option on every single request, which will measurably slow the site down. Instead, flush it exactly once, on plugin activation:

register_activation_hook( __FILE__, function () {
    // Make sure the post type is registered before flushing.
    tidewave_register_product_post_type();
    flush_rewrite_rules();
} );

register_deactivation_hook( __FILE__, function () {
    flush_rewrite_rules();
} );

During development, if you're registering the post type directly in functions.php rather than a plugin with an activation hook, the fastest safe fix is a single manual visit to Settings → Permalinks and clicking Save Changes, which triggers a flush without touching any code.

Custom Capabilities

By default, capability_type => 'post' means product editing permissions map onto the same capabilities as regular posts (edit_posts, publish_posts, and so on), which is usually exactly what you want. If you need finer-grained control, for instance so only Shop Managers, not all Editors, can publish products, register a separate set of capabilities:

'capability_type' => 'product',
'map_meta_cap'    => true,

Then grant the resulting capabilities (edit_product, edit_products, publish_products, delete_products, and so on) to specific roles with add_cap(), typically run once on plugin activation rather than on every request.

Frequently Asked Questions (FAQ) About Custom Post Types

A custom post type creates an entirely separate content type with its own fields, editor screen, and URL structure (products versus posts). A category is a taxonomy term that groups existing posts. If the content has fundamentally different fields or behavior than a blog post, it needs its own post type; if it's just a different grouping of the same kind of content, a category or custom taxonomy is the right tool.

This is almost always a stale rewrite rules cache. Visit Settings → Permalinks and click Save Changes to force a rebuild, and in production code, call flush_rewrite_rules() once on your plugin's activation hook rather than on every page load.

Yes. Use register_taxonomy() with the 'object_type' argument set to include your custom post type's slug alongside 'post' if you want products to share the built-in Category or Tag taxonomies, or register an entirely separate taxonomy just for products.

Only if you set show_in_rest to true in its registration arguments. Once set, it becomes available at /wp-json/wp/v2/ (or the post type slug if rest_base isn't specified), which is also required for the post type to use the block editor instead of the classic editor.

The content itself lives in the wp_posts table regardless of whether the plugin that registered the post type is active, so it isn't deleted. However, if the plugin is deactivated, the post type stops being registered, so its content becomes inaccessible through the normal admin UI and front end until the registration code runs again.

If it's a one-off page, a page template is simpler. Reach for a custom post type once you have several instances of structurally identical content that needs its own listing, archive, or set of custom fields, like a set of case studies or team member profiles.

There's no hard limit enforced by WordPress core, though each one adds admin menu items and, if public, contributes rewrite rules that slightly increase the cost of every URL match. Dozens of post types on one site is common and fine in practice.

Conclusion

register_post_type() is one function, but the arguments it accepts control an enormous amount of behavior: what shows in the admin menu, whether it has its own URLs, which editor fields are available, and whether it's reachable through the REST API and block editor. Start with the full labels array and an explicit supports list even for a simple type, since retrofitting labels later means going back through every screen to check nothing reads oddly.

Once a post type is registered, pair it with a custom taxonomy for categorization and ACF fields for structured data, and you have the building blocks for essentially any content model a project needs, without touching the database directly.

Here are a few additional resources if you want to go deeper:

Tags :
Share :

Related Posts

Effortlessly Crafting Compelling WordPress Pages

Effortlessly Crafting Compelling WordPress Pages

As a website owner or content creator, having the ability to seamlessly add new pages to your WordPress site is crucial. Whether you're introducing a

Continue Reading
High Traffic Tips for WordPress Mastery 🚥

High Traffic Tips for WordPress Mastery 🚥

In our digital age, where online visibility is paramount, ensuring your WordPress site can handle surging traffic is crucial. Just like a finely-tune

Continue Reading
How Do I Change the WordPress Login URL?

How Do I Change the WordPress Login URL?

By default, every WordPress site's login page lives at the same predictable address: yoursite.com/wp-login.php (which also happens to redirect from

Continue Reading