Custom Post Types (CPTs) let you add new content types to WordPress beyond the default Posts and Pages: products, portfolio items, events, recipes, properties, podcasts, anything that needs its own editor flow, its own admin menu, and its own URL structure. You register them with a single PHP function (register_post_type()) and WordPress treats them as first-class citizens from that point on.
This guide covers what CPTs actually are, when to use them (and when not to), how to register one from scratch with annotated code, every option that matters in the $args array, how taxonomies and permalinks fit in, how to expose CPTs to the REST API, the template files WordPress will pick up automatically, and when to reach for a plugin (ACF, CPT UI, Pods) instead of writing the code yourself.
What Are Custom Post Types?
WordPress ships with several built-in post types. The five classic ones are Post, Page, Attachment, Revision, and Nav Menu Item. The block editor era added a few more internal types: wp_block (reusable blocks), wp_template, wp_template_part, and wp_navigation. All of them share the same underlying database table (wp_posts), the same edit screen, and the same query system. What makes them different is metadata: each row in wp_posts has a post_type column that tells WordPress how to treat it.
A custom post type is just a new value for that column, plus a registration that tells WordPress what UI to render, what URL structure to use, what taxonomies (categories/tags) it supports, and what features (editor, thumbnail, comments) it exposes. From an architectural standpoint there’s no second database table involved. WooCommerce products are a CPT. So is every Elementor template, every Contact Form 7 form, every ACF field group.
The reason this matters: once you register a CPT, the entire WordPress ecosystem (block editor, REST API, admin menus, theme template hierarchy, search, archives) treats it as a real content type. You get a working edit screen, a working list table, archive URLs, and REST endpoints with one function call.
When to Use a Custom Post Type (and When to Skip)
Custom post types are the right answer when:
- The content has its own editorial schema. Portfolio items have a client, a date, and an image gallery. Events have a venue and a start time. Recipes have ingredients and instructions. Each needs its own fields, list view, and URL prefix.
- You want it in its own admin menu. Editors should see “Portfolio” and “Events” as distinct top-level items, not mixed into Posts.
- You want URL separation.
yoursite.com/events/wordcamp-2026/reads cleaner thanyoursite.com/2026/01/wordcamp-2026/and lets you build event-specific archive pages. - You’re building for headless WordPress. CPTs are how you model the content schema your frontend consumes. Without CPTs you’re shoving everything into Posts with category tags as the type discriminator, which gets messy fast.
Skip CPTs and just use regular Posts when:
- The content is editorially identical to your blog posts. If “Articles” and “News” are both just articles with a different label, use a category, not a CPT.
- You’d only have a handful of entries. A CPT registration that supports five items isn’t earning its complexity.
- The differentiator is purely visual (different layout) rather than structural (different fields). A child theme or block variation handles that more cleanly.
How to Register a Custom Post Type
The function is register_post_type(). It takes two arguments: a string identifier (the post type “key”) and an array of registration options.
The minimal viable CPT registration looks like this. Drop it in your theme’s functions.php file or a custom plugin:
add_action( 'init', 'smartwp_register_portfolio_cpt' );
function smartwp_register_portfolio_cpt() {
register_post_type( 'portfolio', array(
'label' => 'Portfolio',
'public' => true,
'has_archive' => true,
'show_in_rest' => true,
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
'menu_icon' => 'dashicons-portfolio',
'rewrite' => array( 'slug' => 'portfolio' ),
) );
}
That’s enough to get a working “Portfolio” item in your admin menu, an archive page at yoursite.com/portfolio/, individual items at yoursite.com/portfolio/item-slug/, block-editor support, and REST API endpoints. Two important things about that code:
- Register on the
initaction. Per the official reference: “Post type registrations should not be hooked before the ‘init’ action.” Hooking onplugins_loadedorafter_setup_themecan cause subtle bugs inWP_Query, search, and admin UI. - The post type key is max 20 characters, lowercase, alphanumeric with hyphens or underscores. Don’t prefix with
wp_; that’s reserved for core.
The key arguments inside the $args array, ranked roughly by how often you’ll touch them:
label/labels: the display name shown in admin menus.labelis the short singular;labelsis a longer array of contextual strings (Add New, Edit, View, Search, etc.) that polish the admin UI.public: defaults to false. Set to true to make the CPT visible to authors in admin and viewable on the front end. This is the master switch.has_archive: defaults to false. Set to true to enable an archive page atyoursite.com/{cpt-slug}/that lists all entries.show_in_rest: defaults to false. Set to true to expose the CPT in the REST API and to enable the block editor for that CPT.supports: array of core features the CPT supports (title, editor, thumbnail, excerpt, etc.). Detailed list below.hierarchical: defaults to false. Set to true to allow parent/child relationships (like Pages). Be careful at scale: WordPress fetches every hierarchical post’s ID on each admin page load, which becomes a performance problem as the CPT grows.menu_icon: a Dashicon class string (dashicons-portfolio) or a URL to an SVG/PNG.menu_position: integer controlling where the CPT appears in the admin menu. 5 = below Posts, 20 = below Pages, 100 = at the bottom.rewrite: array controlling URL structure.'slug' => 'portfolio'changes the URL prefix;'with_front' => falsedrops any global permalink front.taxonomies: array of taxonomy slugs to associate. Passarray( 'category', 'post_tag' )to reuse Posts’ default taxonomies, or your own custom taxonomy slug.capability_type: defaults to “post”. Change to “page” if you want page-like capability mapping, or a custom string for entirely separate permission control.
The supports Array: What Each Feature Does
The supports array controls which core editor features show up for your CPT. Pass only what you need; extras add clutter to the editor.
title: the post title field.editor: the main content editor (block editor ifshow_in_restis true, classic otherwise).thumbnail: the featured image picker (your theme must also calladd_theme_support( 'post-thumbnails' )for this to work).excerpt: the manual excerpt field.author: the author selector.comments: comments meta box and front-end comment display.revisions: autosave and revision history.custom-fields: the legacy custom-fields meta box (rarely needed in 2026; use ACF or the Block Bindings API instead).page-attributes: the parent/menu-order sidebar; requires'hierarchical' => truefor the parent dropdown to show.post-formats: post format support (rarely useful).trackbacks: pingbacks/trackbacks (basically never enable in 2026).
For a typical content-type CPT (portfolio, events, products in a non-WooCommerce context), array( 'title', 'editor', 'thumbnail', 'excerpt' ) covers about 90% of what you’ll actually use.
Custom Taxonomies for CPTs
Categories and Tags are taxonomies that ship with the default Post type. For a CPT, you’ll often want your own version: portfolio “Skills” instead of categories, event “Locations” instead of tags. Register them with register_taxonomy():
add_action( 'init', 'smartwp_register_portfolio_taxonomies' );
function smartwp_register_portfolio_taxonomies() {
register_taxonomy( 'skill', 'portfolio', array(
'label' => 'Skills',
'public' => true,
'hierarchical' => false,
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'skill' ),
) );
}
That registers a “Skill” taxonomy attached to the “portfolio” CPT, with its own admin UI, REST exposure, and URL structure at yoursite.com/skill/php/.
hierarchical => true makes the taxonomy behave like categories (parent/child, checkbox UI in the editor). false makes it behave like tags (flat, free-form input).
You can also attach an existing taxonomy to a new CPT by including its slug in the CPT’s taxonomies argument (useful when you want categories shared across Posts and a CPT).
Exposing CPTs to the REST API
Setting 'show_in_rest' => true does two things: it exposes the CPT in the WordPress REST API (you can fetch entries at /wp-json/wp/v2/{cpt-rest-base}/), and it enables the block editor for that CPT. Without it, your CPT falls back to the classic editor and won’t appear in any REST-API-consuming tool.
By default, the REST base matches the CPT key. To customize it, set 'rest_base' in the args:
register_post_type( 'portfolio', array(
'public' => true,
'show_in_rest' => true,
'rest_base' => 'projects', // /wp-json/wp/v2/projects/ instead of /wp-json/wp/v2/portfolio/
// ...other args
) );
For headless setups in particular, show_in_rest is non-negotiable: the entire architecture depends on the frontend being able to fetch CPT entries via REST or WPGraphQL.
CPTs and Permalinks
When you register a CPT with 'public' => true, WordPress adds rewrite rules so the new URLs work. The rewrite argument controls the structure:
'slug' => 'portfolio'sets the URL prefix. Individual entries:yoursite.com/portfolio/sample-project/. Archive:yoursite.com/portfolio/.'with_front' => falsedrops the global permalink front, so the CPT URL isn’t affected by your overall permalink structure.'feeds' => trueenables a CPT-specific RSS feed atyoursite.com/portfolio/feed/.
After registering a new CPT (or changing its rewrite), visit Settings > Permalinks and click Save Changes. That flushes the rewrite rules so the new URLs start working. Skip this step and you’ll get 404s on every CPT entry. From the command line, wp rewrite flush via WP-CLI does the same thing.
Template Files for CPTs
WordPress’s template hierarchy picks up CPT-specific template files automatically. You don’t have to register them; just create them in your theme directory.
single-{cpt}.php: the template for a single CPT entry.single-portfolio.phprenders an individual portfolio item.archive-{cpt}.php: the archive listing for all entries of that CPT.archive-portfolio.phprenders the/portfolio/archive page.taxonomy-{taxonomy}.php: the template for taxonomy term archives.taxonomy-skill.phprenders/skill/php/term pages.- If none of those exist, WordPress falls back to
single.phpandarchive.php, then toindex.php.
For block themes, the equivalent is creating template files in the theme’s /templates/ directory: single-portfolio.html, archive-portfolio.html, etc. The Site Editor will pick them up automatically.
CPT via Plugin: ACF, CPT UI, or Pods
If you’d rather not write code, three plugins handle CPT registration through a UI. Each has trade-offs.
- Custom Post Type UI (CPT UI): a free, focused plugin that does only CPTs and taxonomies. The UI maps almost 1:1 to
register_post_type()arguments, so you learn the underlying API as you use it. Best pick if you want a UI but plan to graduate to code eventually. - Advanced Custom Fields (ACF) Pro: primarily a custom-fields plugin but added CPT and taxonomy registration in version 6.1 (2023). If you’re already using ACF for custom fields, doing CPTs through it keeps everything in one place. The free version doesn’t include the CPT UI; you need ACF Pro.
- Pods: the heaviest option but the most complete. Handles CPTs, taxonomies, custom fields, custom database tables, and content relationships. Powerful but opinionated; the learning curve is steeper than CPT UI or ACF.
If you’re comfortable with PHP, registering CPTs in functions.php or a small custom plugin is cleaner than depending on a plugin for the registration. The code is short, lives in version control, and travels with your theme or site. CPT UI is the right pick when you want the speed of a UI without giving up portability. You can export the configuration as PHP and drop the plugin later if you want.
Frequently Asked Questions
Where should I put register_post_type() code?
In a custom plugin (preferred for portability) or your theme’s functions.php file. The choice matters when you switch themes: code in functions.php disappears with the theme, code in a plugin survives. For anything more than a quick experiment, use a small plugin file in /wp-content/plugins/. Hook the registration on the init action; hooking earlier (like plugins_loaded) can cause subtle issues.
Why do my custom post type URLs return 404?
You registered the CPT but didn’t flush WordPress’s rewrite rules. Go to Settings > Permalinks and click Save Changes (you don’t have to change anything). That regenerates the rewrite rules and the CPT URLs start working. From the command line, wp rewrite flush does the same thing. You need to do this any time you register a new CPT, register a new taxonomy, or change a CPT’s rewrite argument.
What’s the difference between hierarchical and non-hierarchical CPTs?
Hierarchical CPTs (like Pages) support parent/child relationships within the same post type. You can nest one “Portfolio” item under another to create /portfolio/parent-item/child-item/ URLs. Non-hierarchical CPTs (like Posts) are flat. Pick hierarchical only when the content genuinely has a parent/child relationship; otherwise stick with flat for simpler URLs and better performance. WordPress fetches every hierarchical post’s ID on each admin page load, which becomes a performance problem as the CPT grows.
How do I make my custom post type appear in the block editor?
Set 'show_in_rest' => true in the registration args, and include 'editor' in the supports array. Without show_in_rest the CPT falls back to the classic editor regardless of what’s in supports. With both set, the block editor activates automatically.
How many custom post types can I register?
There’s no hard limit. Practically, sites with more than 5-10 CPTs usually mean the data model is over-engineered. Each CPT adds an admin menu item, a set of rewrite rules, and REST routes. Before adding a sixth or seventh, ask whether a custom taxonomy or a category on an existing CPT would solve the same problem with less overhead.
Will my custom post types break if I switch themes?
Only if you registered the CPT in functions.php of the old theme. The CPT registration code goes away with the theme, and the entries become orphans (still in the database but no longer accessible because nothing tells WordPress what they are). The fix is to move CPT registrations into a custom plugin instead of functions.php. Once registered in a plugin, the CPT survives theme switches.
Should I use a plugin or write the code myself?
If you’re comfortable with PHP, write the code. It’s short (10-15 lines for a typical CPT), lives in version control, and travels with your site. If you’d rather not touch code, CPT UI is the cleanest plugin pick because its interface maps directly to the underlying API. If you’re already using ACF Pro for custom fields, doing CPTs through ACF keeps everything in one place. Pods is the heaviest of the three; use it when you need its custom-database-tables and content-relationships features specifically.
Can I rename or delete a custom post type later?
You can rename it (change label, labels, menu_icon) freely; the existing entries keep working. You cannot easily change the post type key (the first argument to register_post_type) without orphaning every existing entry, since the key is what links entries in wp_posts to their type. If you need to change the key, you’ll need a database migration to update the post_type column on all existing entries. Deleting a CPT is the same story: remove the registration and the entries become invisible orphans in the database. Best practice is to write a migration plugin that converts existing entries to a different type before removing the registration.
Wrapping Up
Custom Post Types are how WordPress scales beyond “blog with pages.” Once you’ve registered one, every WordPress system (block editor, REST API, admin UI, template hierarchy, search, archives) treats it as a first-class content type. The barrier to entry is one function call and an init action hook.
For most use cases the safe defaults are: register the CPT in a small custom plugin (not functions.php), set 'show_in_rest' => true for block-editor and REST API support, register a custom taxonomy if the CPT needs its own categories/tags, and flush rewrite rules by visiting Settings > Permalinks after every change.
For deeper context on the surrounding WordPress pieces: functions.php covers where the registration code lives, the REST API guide covers what show_in_rest exposes, the permalinks guide covers the rewrite-rule side, and headless WordPress covers why CPTs are the foundation of any decoupled WP build. The canonical reference is register_post_type() on developer.wordpress.org.


