
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:
labelscovers 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.supportscontrols 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_iconaccepts 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: trueis 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: truegenerates an automatic archive page at/products/(controlled separately byrewrite.slug) listing all published products, using your active theme'sarchive.phporarchive-product.phptemplate if one exists.rewrite.slugdecouples the URL segment from the post type's internal name. Here, the post type isproduct(singular, used in code) but URLs useproducts(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 tosingle.php, thenindex.php).archive-product.php— the/products/archive listing (falls back toarchive.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:
- WordPress Developer Reference: register_post_type() — the complete list of accepted arguments.
- WordPress Developer Handbook: Post Types — the official plugin handbook chapter on custom post types.
- WordPress Template Hierarchy — how WordPress resolves which template file renders a given post type.


