WordPress ships with several built-in content types: posts, pages, attachments, revisions, and navigation menus. For most blogs and simple websites, posts and pages cover everything you need. But what happens when your content does not fit neatly into either category?
That is where Custom Post Types (CPTs) come in. A custom post type lets you define an entirely new content structure in WordPress, complete with its own admin menu, editor screen, archive page, and URL pattern. If you have ever needed to manage portfolios, testimonials, events, team members, or products, CPTs are the right tool for the job.
In this guide, you will learn exactly when custom post types make sense (and when they do not), how to register them with register_post_type(), how to pair them with custom taxonomies, and how to display them on the front end with template files and WP_Query. Every code example is production-ready and follows current WordPress coding standards.
What Are Custom Post Types?
At its core, a post type is a way WordPress categorizes content internally. The built-in post types include post, page, attachment, revision, and nav_menu_item. Each one has specific behavior: posts appear in your blog feed and support categories and tags, pages are hierarchical and do not appear in feeds, and so on.
A custom post type is simply a new entry in this system. When you register a CPT called portfolio, WordPress creates a new database classification, admin menu item, and set of URL routes for that content type. The data still lives in the familiar wp_posts table, but WordPress treats it as a distinct content type with its own rules.
According to the WordPress Developer Handbook, custom post types have been a core feature since WordPress 3.0 (released in 2010), and they remain one of the most powerful ways to extend WordPress beyond a simple blogging platform.
When Do You Actually Need a Custom Post Type?
Not every piece of content requires its own post type. Before registering a new CPT, ask yourself these questions:
- Does this content have a fundamentally different structure? A testimonial has an author name, company, and rating. A blog post does not. Different structures suggest different post types.
- Should this content appear in the main blog feed? If not, it probably should not be a regular post.
- Does this content need its own archive and single page layout? Portfolio items need a grid layout, not a chronological blog listing.
- Will editors need to manage this content independently? Separate admin menus reduce confusion when multiple content types exist.
If you answered yes to two or more of those questions, a custom post type is the right approach. If you just need to categorize blog posts differently, use categories or tags instead. And if you are still setting up your WordPress site, make sure you have chosen a solid theme first before adding custom post types.
CPT vs. Categories and Tags
| Scenario | Solution | Reason |
|---|---|---|
| Blog posts about different topics | Categories/Tags | Same content structure, just different subjects |
| Portfolio projects with client info, project URL, gallery | Custom Post Type | Unique data structure, separate archive layout |
| Product reviews mixed with regular articles | Category (Reviews) | Same writing format, different topic grouping |
| Events with dates, locations, ticket links | Custom Post Type | Completely different data fields and display |
| Team member profiles | Custom Post Type | Unique fields (role, bio, photo), grid display |
Registering a Custom Post Type with register_post_type()
The register_post_type() function is the foundation of every CPT. You call it on the init hook, passing a post type slug and an array of arguments that control everything from the admin menu label to REST API visibility.
Here is a complete, production-ready example that registers a Portfolio post type:
/**
* Register Portfolio Custom Post Type
*
* @link https://developer.wordpress.org/reference/functions/register_post_type/
*/
function wpp_register_portfolio_cpt() {
= array(
'name' => _x( 'Portfolios', 'Post type general name', 'theme-textdomain' ),
'singular_name' => _x( 'Portfolio', 'Post type singular name', 'theme-textdomain' ),
'menu_name' => _x( 'Portfolios', 'Admin Menu text', 'theme-textdomain' ),
'name_admin_bar' => _x( 'Portfolio', 'Add New on Toolbar', 'theme-textdomain' ),
'add_new' => __( 'Add New', 'theme-textdomain' ),
'add_new_item' => __( 'Add New Portfolio', 'theme-textdomain' ),
'new_item' => __( 'New Portfolio', 'theme-textdomain' ),
'edit_item' => __( 'Edit Portfolio', 'theme-textdomain' ),
'view_item' => __( 'View Portfolio', 'theme-textdomain' ),
'all_items' => __( 'All Portfolios', 'theme-textdomain' ),
'search_items' => __( 'Search Portfolios', 'theme-textdomain' ),
'not_found' => __( 'No portfolios found.', 'theme-textdomain' ),
'not_found_in_trash' => __( 'No portfolios found in Trash.', 'theme-textdomain' ),
'featured_image' => _x( 'Portfolio Cover Image', 'Overrides the Featured Image phrase', 'theme-textdomain' ),
'set_featured_image' => _x( 'Set cover image', 'Overrides the Set featured image phrase', 'theme-textdomain' ),
'remove_featured_image' => _x( 'Remove cover image', 'Overrides the Remove featured image phrase', 'theme-textdomain' ),
'use_featured_image' => _x( 'Use as cover image', 'Overrides the Use as featured image phrase', 'theme-textdomain' ),
'archives' => _x( 'Portfolio archives', 'The post type archive label', 'theme-textdomain' ),
'filter_items_list' => _x( 'Filter portfolios list', 'Screen reader text', 'theme-textdomain' ),
'items_list_navigation' => _x( 'Portfolios list navigation', 'Screen reader text', 'theme-textdomain' ),
'items_list' => _x( 'Portfolios list', 'Screen reader text', 'theme-textdomain' ),
);
= array(
'labels' => ,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_rest' => true,
'query_var' => true,
'rewrite' => array( 'slug' => 'portfolio' ),
'capability_type' => 'post',
'has_archive' => true,
'hierarchical' => false,
'menu_position' => 20,
'menu_icon' => 'dashicons-portfolio',
'supports' => array(
'title',
'editor',
'author',
'thumbnail',
'excerpt',
'custom-fields',
'revisions',
),
'taxonomies' => array( 'portfolio_category', 'portfolio_tag' ),
);
register_post_type( 'portfolio', );
}
add_action( 'init', 'wpp_register_portfolio_cpt' );
Let us break down the most important arguments.
The Labels Array
The labels array controls every text string in the admin interface. While you can technically get by with just name and singular_name, providing the full set ensures a polished admin experience. WordPress uses these labels for the menu, toolbar, editor notices, list table headers, and screen reader text.
Always wrap labels in translation functions (__() or _x()) even if you are not building a multilingual site. It is a best practice that makes your code translation-ready from the start.
The Supports Array
The supports parameter tells WordPress which editor features to enable for this post type. Here are the available options:
title– The post title fieldeditor– The main content editor (Gutenberg or Classic)author– Author selection dropdownthumbnail– Featured image supportexcerpt– Excerpt meta boxtrackbacks– Trackbacks and pingbackscustom-fields– Custom fields meta box (also needed for meta in REST API)comments– Comment supportrevisions– Revision historypage-attributes– Template and menu order (for hierarchical types)post-formats– Post format support
Only include the features your CPT actually needs. A testimonial post type, for example, might only need title, editor, and thumbnail.
show_in_rest: Essential for the Block Editor
Setting show_in_rest to true does two critical things. First, it exposes your CPT through the WordPress REST API, making it accessible at /wp-json/wp/v2/portfolio. Second, and more importantly, it enables the Gutenberg block editor for your post type.
Without show_in_rest => true, your CPT will fall back to the Classic Editor. If you want your content creators to use the full block editor experience with your custom post type, this setting is non-negotiable.
If you are registering a custom post type in 2024 or later and you omit show_in_rest, you are effectively opting out of the modern WordPress editing experience.
WordPress Developer Handbook, REST API Documentation
Key Arguments Reference
| Argument | Type | What It Controls | Recommended Value |
|---|---|---|---|
public | bool | Front-end visibility and admin UI | true |
show_in_rest | bool | REST API access and Gutenberg support | true |
has_archive | bool | Whether an archive page exists at /portfolio/ | true for most CPTs |
hierarchical | bool | Parent-child relationships (like pages) | false unless needed |
rewrite | array | URL slug pattern | array( slug => your-slug ) |
menu_icon | string | Admin menu Dashicon | Any Dashicons class |
capability_type | string | Permission mapping | post for standard permissions |
Adding Custom Taxonomies with register_taxonomy()
Custom post types become far more useful when paired with custom taxonomies. While you could reuse the built-in category and post_tag taxonomies, creating dedicated taxonomies keeps your content organized and avoids mixing unrelated terms.
Here is how to register a hierarchical taxonomy (like categories) and a non-hierarchical one (like tags) for the Portfolio CPT:
/**
* Register Portfolio Taxonomies
*
* @link https://developer.wordpress.org/reference/functions/register_taxonomy/
*/
function wpp_register_portfolio_taxonomies() {
// Hierarchical taxonomy (like categories)
= array(
'name' => _x( 'Project Types', 'taxonomy general name', 'theme-textdomain' ),
'singular_name' => _x( 'Project Type', 'taxonomy singular name', 'theme-textdomain' ),
'search_items' => __( 'Search Project Types', 'theme-textdomain' ),
'all_items' => __( 'All Project Types', 'theme-textdomain' ),
'parent_item' => __( 'Parent Project Type', 'theme-textdomain' ),
'parent_item_colon' => __( 'Parent Project Type:', 'theme-textdomain' ),
'edit_item' => __( 'Edit Project Type', 'theme-textdomain' ),
'update_item' => __( 'Update Project Type', 'theme-textdomain' ),
'add_new_item' => __( 'Add New Project Type', 'theme-textdomain' ),
'new_item_name' => __( 'New Project Type Name', 'theme-textdomain' ),
'menu_name' => __( 'Project Types', 'theme-textdomain' ),
);
register_taxonomy( 'project_type', array( 'portfolio' ), array(
'hierarchical' => true,
'labels' => ,
'show_ui' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'query_var' => true,
'rewrite' => array( 'slug' => 'project-type' ),
) );
// Non-hierarchical taxonomy (like tags)
= array(
'name' => _x( 'Skills', 'taxonomy general name', 'theme-textdomain' ),
'singular_name' => _x( 'Skill', 'taxonomy singular name', 'theme-textdomain' ),
'search_items' => __( 'Search Skills', 'theme-textdomain' ),
'popular_items' => __( 'Popular Skills', 'theme-textdomain' ),
'all_items' => __( 'All Skills', 'theme-textdomain' ),
'edit_item' => __( 'Edit Skill', 'theme-textdomain' ),
'update_item' => __( 'Update Skill', 'theme-textdomain' ),
'add_new_item' => __( 'Add New Skill', 'theme-textdomain' ),
'new_item_name' => __( 'New Skill Name', 'theme-textdomain' ),
'separate_items_with_commas' => __( 'Separate skills with commas', 'theme-textdomain' ),
'add_or_remove_items' => __( 'Add or remove skills', 'theme-textdomain' ),
'choose_from_most_used' => __( 'Choose from the most used skills', 'theme-textdomain' ),
'not_found' => __( 'No skills found.', 'theme-textdomain' ),
'menu_name' => __( 'Skills', 'theme-textdomain' ),
);
register_taxonomy( 'skill', array( 'portfolio' ), array(
'hierarchical' => false,
'labels' => ,
'show_ui' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'update_count_callback' => '_update_post_term_count',
'query_var' => true,
'rewrite' => array( 'slug' => 'skill' ),
) );
}
add_action( 'init', 'wpp_register_portfolio_taxonomies' );
Note the show_in_rest => true on both taxonomies. Just like with post types, this is required for block editor support. Without it, the taxonomy meta boxes will not appear in the Gutenberg sidebar.
Real-World CPT Examples
Let us look at four practical examples showing how different content types translate to CPT registrations. Each example includes only the arguments that differ from the Portfolio example above.
Testimonials
register_post_type( 'testimonial', array(
'labels' => array(
'name' => 'Testimonials',
'singular_name' => 'Testimonial',
),
'public' => true,
'show_in_rest' => true,
'has_archive' => false,
'menu_icon' => 'dashicons-format-quote',
'supports' => array( 'title', 'editor', 'thumbnail' ),
'rewrite' => array( 'slug' => 'testimonials' ),
) );
Testimonials often do not need an archive page because they are displayed using shortcodes or blocks on specific pages. Setting has_archive to false keeps URLs clean.
Events
register_post_type( 'event', array(
'labels' => array(
'name' => 'Events',
'singular_name' => 'Event',
),
'public' => true,
'show_in_rest' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-calendar-alt',
'supports' => array(
'title', 'editor', 'thumbnail', 'excerpt',
'custom-fields', 'revisions',
),
'rewrite' => array( 'slug' => 'events' ),
) );
Events benefit from the excerpt support for displaying short summaries in listings, and custom-fields to store event-specific metadata like dates, venues, and ticket URLs via the REST API or a meta box plugin.
Products (Simple, Non-WooCommerce)
register_post_type( 'product', array(
'labels' => array(
'name' => 'Products',
'singular_name' => 'Product',
),
'public' => true,
'show_in_rest' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-cart',
'supports' => array(
'title', 'editor', 'thumbnail', 'excerpt',
'custom-fields', 'revisions',
),
'rewrite' => array( 'slug' => 'products' ),
'taxonomies' => array( 'product_category' ),
) );
For simple product catalogs that do not need e-commerce functionality (shopping cart, checkout, payment processing), a custom post type is lighter and faster than installing WooCommerce. If you only need to display products with descriptions and images, this approach works well.
Team Members
register_post_type( 'team_member', array(
'labels' => array(
'name' => 'Team',
'singular_name' => 'Team Member',
),
'public' => true,
'show_in_rest' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-groups',
'supports' => array(
'title', 'editor', 'thumbnail', 'custom-fields',
),
'rewrite' => array( 'slug' => 'team' ),
) );
Team member CPTs pair perfectly with custom fields for role, department, social media links, and contact information. Tools like Advanced Custom Fields (ACF) or the built-in custom fields meta box make this straightforward.
Template Files for Custom Post Types
WordPress uses its Template Hierarchy to determine which theme file renders a given page. For custom post types, two template files matter most.
single-{post_type}.php
This template renders individual CPT entries. For a portfolio post type, create single-portfolio.php in your theme:
<?php
get_header();
while ( have_posts() ) :
the_post();
?>
<article id="post-<?php the_ID(); ?>" <?php post_class( 'portfolio-single' ); ?>>
<header class="portfolio-header">
<h1><?php the_title(); ?></h1>
<?php
= get_the_terms( get_the_ID(), 'project_type' );
if ( && ! is_wp_error( ) ) :
= wp_list_pluck( , 'name' );
?>
<div class="project-types">
<?php echo esc_html( implode( ', ', ) ); ?>
</div>
<?php endif; ?>
</header>
<?php if ( has_post_thumbnail() ) : ?>
<div class="portfolio-featured-image">
<?php the_post_thumbnail( 'large' ); ?>
</div>
<?php endif; ?>
<div class="portfolio-content">
<?php the_content(); ?>
</div>
</article>
<?php
endwhile;
get_footer();
archive-{post_type}.php
This template renders the archive listing page (e.g., yoursite.com/portfolio/). Create archive-portfolio.php:
<?php
get_header();
?>
<div class="portfolio-archive">
<h1 class="archive-title">
<?php post_type_archive_title(); ?>
</h1>
<?php if ( have_posts() ) : ?>
<div class="portfolio-grid">
<?php while ( have_posts() ) : the_post(); ?>
<div class="portfolio-card">
<a href="<?php the_permalink(); ?>">
<?php the_post_thumbnail( 'medium_large' ); ?>
<h2><?php the_title(); ?></h2>
</a>
<?php the_excerpt(); ?>
</div>
<?php endwhile; ?>
</div>
<?php the_posts_pagination(); ?>
<?php else : ?>
<p>No portfolio items found.</p>
<?php endif; ?>
</div>
<?php
get_footer();
If you are using a Full Site Editing (FSE) block theme, you would create these templates in the Site Editor instead of as PHP files. Navigate to Appearance, then Editor, then Templates and add a new template for your custom post type.
Querying Custom Post Types with WP_Query
While template files handle the default views, you will often need to display CPT content in other parts of your site: on the homepage, in sidebars, or within other pages. WP_Query makes this straightforward.
Basic CPT Query
= new WP_Query( array(
'post_type' => 'portfolio',
'posts_per_page' => 6,
'orderby' => 'date',
'order' => 'DESC',
) );
if ( ->have_posts() ) :
while ( ->have_posts() ) :
->the_post();
the_title( '<h3>', '</h3>' );
the_excerpt();
endwhile;
wp_reset_postdata();
endif;
Filtering by Custom Taxonomy
= new WP_Query( array(
'post_type' => 'portfolio',
'posts_per_page' => 12,
'tax_query' => array(
array(
'taxonomy' => 'project_type',
'field' => 'slug',
'terms' => 'web-design',
),
),
) );
Querying Multiple Post Types
= new WP_Query( array(
'post_type' => array( 'post', 'portfolio' ),
'posts_per_page' => 10,
'orderby' => 'date',
'order' => 'DESC',
) );
Always call wp_reset_postdata() after a custom WP_Query loop. This restores the global variable to the original query, preventing bugs in sidebars, footers, and other template parts.
Where to Put Your CPT Code
You have three options for placing your register_post_type() code, each with tradeoffs:
| Location | Pros | Cons | Best For |
|---|---|---|---|
| Theme functions.php | Simple, no extra files | Lose CPTs when switching themes | Theme-specific content |
| Custom plugin | Persists across theme changes, portable | One more plugin to maintain | Core site content (portfolio, products) |
| Must-use plugin (mu-plugins) | Always active, cannot be deactivated | No update mechanism | Critical CPTs that must never be disabled |
The WordPress community consensus, echoed in the official Plugin Handbook, is that CPTs should live in a plugin rather than your theme. The reasoning is straightforward: if you switch themes, your content disappears from the admin and front end. Placing CPT registrations in a plugin ensures your data remains accessible regardless of which theme is active. If you are writing plugin code, make sure to also check PHP 8.x compatibility requirements for your custom post type code.
Plugins for Non-Coders: CPT UI and ACF
Not everyone wants to write PHP to create custom post types. Two plugins make CPT creation accessible through the WordPress admin interface.
Custom Post Type UI (CPT UI)
CPT UI provides an admin interface for registering post types and taxonomies without writing code. With over 1 million active installations, it is the most popular CPT plugin in the WordPress repository. You fill in the same options that register_post_type() accepts, and the plugin handles the registration for you.
CPT UI also includes an export feature that generates the equivalent PHP code, which you can then paste into a plugin or theme. This makes it a useful learning tool even if you plan to eventually write the code yourself.
Advanced Custom Fields (ACF)
Advanced Custom Fields takes a broader approach. While it started as a custom fields plugin, ACF Pro now includes post type and taxonomy registration built right into its interface. More importantly, ACF lets you attach structured custom fields (text, images, repeaters, relationships, date pickers, and dozens more) to your CPTs.
For example, you could create an Events CPT with ACF and add fields for event date, venue address, ticket price, and registration URL without writing any PHP. ACF stores this data as post meta and provides template functions like get_field() and the_field() for front-end display.
Common Mistakes and How to Avoid Them
After working with custom post types for years, these are the mistakes that come up most frequently:
- Forgetting to flush rewrite rules. After registering a new CPT, WordPress needs to rebuild its rewrite rules. Visit Settings then Permalinks and click Save Changes (you do not need to change anything). This flushes the rewrite rules and makes your CPT URLs work. Alternatively, call
flush_rewrite_rules()on plugin activation, but never on every page load. - Using a reserved slug. WordPress reserves certain slugs for internal use:
post,page,attachment,revision,nav_menu_item,action,author,order, andtheme. Using any of these will cause conflicts. Check the reserved post types list before choosing your slug. - Omitting show_in_rest. As discussed earlier, this locks your CPT out of the block editor. Always set it to
trueunless you have a specific reason not to. - Registering CPTs too late. Always use the
inithook. Registering onadmin_initor later means your CPT will not be available for front-end queries, REST API endpoints, or rewrite rules. - Slug conflicts with pages. If you have a WordPress page with the slug portfolio and a CPT archive at /portfolio/, they will conflict. Either rename the page or change the CPT rewrite slug.
Putting It All Together: A Complete Plugin
Here is a complete, self-contained plugin file that registers a Portfolio CPT with custom taxonomies and proper activation/deactivation hooks. You can use this as a template for any custom post type:
<?php
/**
* Plugin Name: Portfolio Custom Post Type
* Description: Registers a Portfolio CPT with Project Type and Skill taxonomies.
* Version: 1.0.0
* Author: Your Name
* Text Domain: portfolio-cpt
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function portfolio_cpt_register() {
register_post_type( 'portfolio', array(
'labels' => array(
'name' => _x( 'Portfolios', 'post type general name', 'portfolio-cpt' ),
'singular_name' => _x( 'Portfolio', 'post type singular name', 'portfolio-cpt' ),
'menu_name' => _x( 'Portfolios', 'admin menu', 'portfolio-cpt' ),
'add_new_item' => __( 'Add New Portfolio', 'portfolio-cpt' ),
'edit_item' => __( 'Edit Portfolio', 'portfolio-cpt' ),
'view_item' => __( 'View Portfolio', 'portfolio-cpt' ),
'all_items' => __( 'All Portfolios', 'portfolio-cpt' ),
'search_items' => __( 'Search Portfolios', 'portfolio-cpt' ),
'not_found' => __( 'No portfolios found.', 'portfolio-cpt' ),
'not_found_in_trash' => __( 'No portfolios found in Trash.', 'portfolio-cpt' ),
),
'public' => true,
'show_in_rest' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'portfolio' ),
'menu_icon' => 'dashicons-portfolio',
'supports' => array(
'title', 'editor', 'thumbnail', 'excerpt',
'custom-fields', 'revisions',
),
) );
register_taxonomy( 'project_type', 'portfolio', array(
'labels' => array(
'name' => _x( 'Project Types', 'taxonomy general name', 'portfolio-cpt' ),
'singular_name' => _x( 'Project Type', 'taxonomy singular name', 'portfolio-cpt' ),
'add_new_item' => __( 'Add New Project Type', 'portfolio-cpt' ),
),
'hierarchical' => true,
'show_ui' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'project-type' ),
) );
register_taxonomy( 'skill', 'portfolio', array(
'labels' => array(
'name' => _x( 'Skills', 'taxonomy general name', 'portfolio-cpt' ),
'singular_name' => _x( 'Skill', 'taxonomy singular name', 'portfolio-cpt' ),
'add_new_item' => __( 'Add New Skill', 'portfolio-cpt' ),
),
'hierarchical' => false,
'show_ui' => true,
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'skill' ),
) );
}
add_action( 'init', 'portfolio_cpt_register' );
function portfolio_cpt_activate() {
portfolio_cpt_register();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'portfolio_cpt_activate' );
function portfolio_cpt_deactivate() {
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'portfolio_cpt_deactivate' );
Save this as portfolio-cpt.php in your wp-content/plugins/ directory, activate it, and you will have a fully functional Portfolio section with custom taxonomies, Gutenberg support, and clean URLs.
Frequently Asked Questions
Do custom post types affect site performance?
Registering custom post types has negligible impact on performance. The register_post_type() call itself is lightweight. Performance concerns only arise when you have thousands of posts with complex meta queries. For most sites, CPTs are not a bottleneck.
Can I convert existing posts to a custom post type?
Yes. You can change the post_type column in the wp_posts table directly via SQL, or use a plugin like Post Type Switcher that adds a post type dropdown to the editor. Always back up your database before making bulk changes.
What happens to CPT data if I deactivate the plugin?
The data remains in your database in the wp_posts table. It just becomes invisible in the admin and front end because WordPress no longer recognizes that post type. Reactivating the plugin (or re-registering the post type) brings everything back immediately.
Next Steps
Custom post types are one of the features that make WordPress a genuine application framework rather than just a blogging tool. Once you are comfortable with register_post_type() and register_taxonomy(), you can build virtually any content structure your project requires.
To go further, explore adding custom meta boxes with add_meta_box() or use ACF to create rich field groups for your CPTs. Look into custom REST API endpoints if you need to expose CPT data to external applications or JavaScript-heavy front ends. And if you are building a theme, study the complete WordPress Template Hierarchy to understand every template file that can target your custom post type.
Start with a single CPT for your most pressing content need, get it working end to end, and build from there. The pattern is always the same: register the post type, add taxonomies if needed, create template files, and query with WP_Query. Once you have done it once, every subsequent custom post type takes minutes to set up.
Custom Post Types Custom Taxonomies register_post_type WordPress Development WP_Query
Last modified: April 2, 2026









