Written by 12:58 pm Custom Post Types Views: 0

WordPress Custom Post Types: Complete Developer Tutorial

Learn how to build WordPress custom post types from scratch. This complete developer tutorial covers register_post_type(), custom taxonomies, meta boxes, Gutenberg block editor support, REST API integration, and ACF vs native meta – everything you need to model any data structure in WordPress.

WordPress Custom Post Types Developer Tutorial - Complete guide covering register_post_type, taxonomies, meta boxes, Gutenberg support, REST API and ACF

WordPress ships with posts and pages out of the box. But the moment your project needs portfolios, events, products, case studies, or any structured data of its own – you need custom post types. This tutorial walks you through every layer of CPT development: registering post types and taxonomies, building meta boxes, enabling Gutenberg and REST API support, and deciding when ACF is worth adding on top of the native tools.


What Are Custom Post Types?

WordPress organises content into post types. The default ones – post, page, attachment, revision, nav_menu_item – are registered by WordPress core at startup. A custom post type (CPT) is simply a new content bucket you define yourself, with its own labels, admin menu entry, archive URL, query behaviour, and capabilities.

If you are building a portfolio site, a job board, a real-estate listing, an events calendar, or any directory-style application in WordPress, you will reach for CPTs almost immediately. They give you a clean, scalable data model without hacking the posts table with awkward category tricks.

A well-designed custom post type turns WordPress from a blog engine into a full application framework – without touching a database schema.


Step 1: Registering a Custom Post Type with register_post_type()

The entry point is the register_post_type() function. Call it on the init hook – never earlier, never in a template file. The function accepts two arguments: a post type slug (lowercase, underscores, 20-character max) and an array of arguments that control every aspect of its behaviour.

Here is a complete, production-ready registration for a “Portfolio” CPT:

Key Arguments Explained

ArgumentWhat It ControlsCommon Values
publicWhether the CPT is visible to the public and in admintrue / false
has_archiveCreates an archive page at /portfolio/true / ‘portfolio-archive’
rewriteThe URL slug for single postsarray(‘slug’ => ‘portfolio’)
supportsWhich built-in features to enabletitle, editor, thumbnail, excerpt
show_in_restEnables the Gutenberg editor and REST APItrue (always set this)
menu_iconThe dashicon shown in the admin sidebar‘dashicons-portfolio’
capability_typeCapability group for read/edit/delete‘post’ / ‘page’ / custom

One argument beginners often overlook is show_in_rest. Setting it to true does two things at once: it exposes the post type through the WP REST API and it switches the admin edit screen from the classic TinyMCE editor to the modern Gutenberg block editor. If you omit it, your CPT gets the legacy editor and cannot be queried from JavaScript without extra work.

After Registration: Flush Rewrite Rules

Every time you change the rewrite slug or add a new CPT, go to Settings > Permalinks and click “Save Changes” to flush WordPress rewrite rules. If you skip this step, your archive and single post URLs will return 404 errors. In production deployments, call flush_rewrite_rules() once on plugin activation – never on every page load.


Step 2: Adding Custom Taxonomies

A taxonomy is a grouping mechanism. WordPress comes with two: categories (hierarchical, like a folder tree) and tags (flat). You can register your own taxonomies and attach them to any post type, built-in or custom.

For the Portfolio CPT, a “Project Type” taxonomy that groups entries into Web Design, Branding, Motion, or Mobile makes sense. Here is how to register it:

Hierarchical vs. Flat Taxonomies

  • Hierarchical (categories) – terms can have parent/child relationships, displayed as checkboxes in the admin. Use this for structured classification where context matters (Clothing > Men > Jackets).
  • Flat (tags) – terms are independent, displayed as a free-text tag input. Use this for unstructured keywords where any combination is valid.

The show_in_rest: true argument is just as important for taxonomies as it is for post types. Without it, the Gutenberg sidebar will not display your taxonomy panel, and REST API clients will not be able to filter by it.

Attaching a Taxonomy to Multiple Post Types

The third argument to register_taxonomy() accepts an array of post type slugs. You can share one taxonomy across several CPTs – for example, a “Location” taxonomy shared by Events, Properties, and Restaurants. Pass array('portfolio', 'project') instead of a single string to link it to both.


Step 3: Building Custom Meta Boxes

Taxonomies classify posts. Meta boxes collect structured extra data that does not fit into the post body. For the Portfolio CPT: the live project URL, the client name, the project budget, the completion date. All of these belong in post meta, not in the editor body.

Meta boxes work in three parts: register the box, render its HTML, save its values. Here is a complete, properly secured implementation:

Security Checklist for Meta Boxes

  • Nonce verification – Always use wp_nonce_field() in the render callback and verify with wp_verify_nonce() in the save callback. This prevents CSRF attacks where a malicious page tricks a logged-in admin into saving bad data.
  • Autosave check – WordPress fires save_post during autosaves. Guard against it with DOING_AUTOSAVE to avoid wasted writes or partial data.
  • Capability check – Verify current_user_can('edit_post', $post_id) before writing. Editors and Contributors should not be able to write arbitrary meta to posts they cannot edit.
  • Input sanitization – Use esc_url_raw() for URLs, sanitize_text_field() for plain text, wp_kses_post() for HTML content, and absint() for integers. Never pass $_POST values directly to update_post_meta().

Step 4: Gutenberg Block Editor Support for Custom Post Types

Setting show_in_rest => true in your CPT registration is the minimum required for Gutenberg. But there are additional steps to make the editor experience smooth and to unlock specific block features.

Supports Array for Gutenberg

The supports array in your register_post_type() call controls what the Gutenberg sidebar shows. Key values for a rich editing experience:

  • title – The post title field at the top of the editor.
  • editor – The main block editor canvas. Omitting this removes the content area entirely.
  • thumbnail – The featured image panel in the sidebar.
  • excerpt – The excerpt panel in the sidebar. Required for SEO plugins to show the excerpt field.
  • custom-fields – Exposes a classic custom fields panel. Required for some meta box plugins. Not needed if you use register_post_meta() with REST API support.
  • revisions – Enables revision history. Important for editorial workflows.
  • page-attributes – Adds the template and page order fields. Only meaningful for hierarchical CPTs.

Showing Your Taxonomy in the Gutenberg Sidebar

When you register a taxonomy with show_in_rest: true, Gutenberg automatically displays it in the Document sidebar under the post editor. The panel will show a checkbox list for hierarchical taxonomies (matching how categories work) and a tag input for flat taxonomies.

If your taxonomy panel does not appear in Gutenberg despite setting show_in_rest, check two things: first, that the show_ui argument is also true; second, that the taxonomy is registered before the Gutenberg editor loads (i.e., on the init hook, not a later hook).


Step 5: REST API Integration for Custom Post Types

Once show_in_rest is true on both the post type and its taxonomies, WordPress exposes full CRUD endpoints automatically. Your Portfolio CPT becomes queryable at /wp-json/wp/v2/portfolio. You can filter, paginate, sort, and embed related resources without writing a single custom route. If you plan to expose sensitive data through these endpoints, make sure to review proper WordPress REST API authentication patterns before going to production.

Exposing Custom Meta Fields via REST

Post meta is not included in REST responses by default. You need to explicitly opt each field into the REST API using register_post_meta():

Common REST API Query Patterns for CPTs

TaskREST Query
Get all portfolio posts/wp-json/wp/v2/portfolio
Filter by taxonomy term slug/wp-json/wp/v2/portfolio?project_type=web-design
Filter by taxonomy term ID/wp-json/wp/v2/portfolio?project_type=14
Embed featured image in response/wp-json/wp/v2/portfolio?_embed
Paginate results/wp-json/wp/v2/portfolio?per_page=10&page=2
Search by keyword/wp-json/wp/v2/portfolio?search=branding
Sort by custom date/wp-json/wp/v2/portfolio?orderby=date&order=asc

For headless WordPress setups – where a Next.js or Nuxt front-end fetches content from the WP REST API – custom post types with proper REST configuration are the backbone of your data layer. The _embed parameter is particularly useful: it inlines the featured image, author, and taxonomy terms in a single request, cutting down on round trips.


Step 6: ACF vs. Native Meta – Which Should You Use?

Advanced Custom Fields (ACF) is by far the most popular plugin for adding structured data to WordPress. But it is not always the right choice. Understanding when to use native WordPress meta and when ACF earns its place will save you plugin overhead and technical debt.

The Decision Matrix

ScenarioNative MetaACF FreeACF Pro
1-3 simple text/number fieldsBest choiceWorksOverkill
Image or file fieldsPossible but verboseBest choiceWorks
Relationship fields (post to post)Possible, no UIBest choiceWorks
Repeater fields (dynamic rows)Complex to buildNot availableBest choice
Flexible content layoutsVery complexNot availableBest choice
Non-developer team editing contentPoor UXGoodBest choice
Plugin/theme for distributionBest (no dependencies)AvoidAvoid
Performance-critical sitesBestMinor overheadModerate overhead

ACF and the REST API

ACF Free does not expose fields to the REST API by default. You need either ACF Pro (which includes a REST API toggle per field group) or the free ACF to REST API plugin. If you are building a headless WordPress application and plan to use ACF, factor this in from day one – switching from native meta to ACF after you have built a REST-dependent front-end means rewriting every API call.


Full Site Editing and Block Templates for Custom Post Types

With WordPress Full Site Editing (FSE) and block themes, the classic PHP template hierarchy is supplemented by block templates stored as HTML files inside the theme. If you are building with a block theme, your CPT needs templates in the right location – and understanding how FSE resolves those templates saves a lot of confusion.

Block Template Hierarchy for CPTs

In a block theme (one with a theme.json file and a templates/ folder), WordPress looks for templates in this order for a single portfolio post:

  1. templates/single-portfolio.html – Specific to the Portfolio CPT
  2. templates/single.html – Generic single post template
  3. templates/singular.html – Fallback for any singular view
  4. templates/index.html – The base catch-all template

For the archive page, the hierarchy is: archive-portfolio.html > archive.html > index.html. You can create these templates directly in the Site Editor (Appearance > Editor > Templates) or by adding the HTML files to your theme’s templates/ directory.

The template Argument in register_post_type()

The register_post_type() function has a template argument that pre-populates new posts with a specific block layout. This is different from FSE templates – it defines the initial content that gets inserted into the Gutenberg editor canvas when a user creates a new post of this type.

For a portfolio CPT, you might pre-fill the editor with a two-column block for project description and details, a gallery block for screenshots, and a button block for the live site link. Users see a structured starting point instead of a blank canvas, which is particularly valuable when non-developer team members are creating content. Pair this with template_lock => 'insert' to prevent adding or removing blocks outside the defined structure.


Querying Custom Post Types in Templates

Once your CPT is registered, you can query it anywhere in your theme with WP_Query. The post type slug is the key argument. You can combine it with taxonomy terms, meta queries, and ordering to build sophisticated archive pages.

For theme template files, WordPress follows a hierarchy for CPT archives and single views. The archive template loads at archive-{post_type}.php (e.g., archive-portfolio.php) and the single post template loads at single-{post_type}.php (e.g., single-portfolio.php). If those files do not exist, WordPress falls back to archive.php and single.php.

Including CPTs in the Main Query

By default, custom post types do not appear in the main blog archive loop or in search results. To include them, hook into pre_get_posts and add your CPT slug to the post_type parameter. Be careful to scope this hook – check is_main_query() and ! is_admin() to avoid interfering with the admin dashboard queries.

Tax Query and Meta Query with Custom Post Types

WP_Query supports two advanced query parameters that make CPTs especially powerful. A tax_query filters posts by taxonomy term, while a meta_query filters by post meta value. Both accept compound conditions with AND/OR relations, which means you can build faceted filtering without writing any raw SQL.

For example, to find all portfolio items in the “Web Design” project type that have a project budget over $10,000, you would combine a tax_query on the project_type taxonomy with a meta_query on the _portfolio_budget meta key. The query structure is declarative and cache-friendly, and WP_Query handles all escaping and preparation internally. Keep the update_post_meta_cache and update_post_term_cache arguments set to false when you only need the post IDs and not the full post objects or meta data – this cuts the number of database queries significantly for large result sets.


Capabilities and User Roles

By default, CPTs inherit their capabilities from the capability_type you set – usually 'post'. This means any user who can edit posts can edit your CPT. For some applications, this is fine. For others – particularly multi-author setups or client-managed sites – you want tighter control.

Setting capability_type => 'portfolio' and map_meta_cap => true tells WordPress to generate a distinct set of capabilities (edit_portfolio, edit_portfolios, publish_portfolios, etc.) for this post type. You can then grant or deny these capabilities to roles using add_cap() or a role management plugin. This pattern is the foundation of multi-role editorial workflows.


Performance Considerations

Custom post types share the same database tables as standard posts (wp_posts, wp_postmeta, wp_term_relationships). There is no separate table per post type. This means the performance implications are the same as for regular posts.

  • Post meta queries are slow at scale – Avoid meta_query comparisons on unindexed meta keys when you have thousands of posts. Add a database index on the meta_key column for frequently queried keys, or move sortable/filterable data to a custom table.
  • Avoid querying all posts without a limit – Never use posts_per_page: -1 in production queries on large data sets. Always paginate.
  • Transient caching – Cache expensive queries with set_transient(). Invalidate on save_post_{post_type} and delete_post hooks.
  • Object cache – On high-traffic sites, connect Redis or Memcached as a WordPress object cache drop-in. WP_Query results are already stored in the object cache between requests in the same page load, but a persistent cache layer extends this across page loads.
  • No-ops for meta caches – When querying for post IDs only, set update_post_meta_cache => false and update_post_term_cache => false in your WP_Query arguments to skip the automatic meta and term cache priming that normally runs after every query.

Avoiding Common CPT Mistakes

  • Using a namespace prefix – Always prefix your post type slug: myplugin_portfolio not portfolio. Post type slugs are global. An unprefixed slug will conflict with other plugins or future WordPress core additions. The 20-character limit includes your prefix.
  • Registering on the wrong hook – Use the init hook. Registering later (on admin_init for example) means the post type is not available for front-end queries and REST API requests.
  • Forgetting flush_rewrite_rules – New CPTs need a permalink flush on activation. Wire it to a plugin activation hook in distributable code; in development, just visit Settings > Permalinks once.
  • Omitting show_in_rest – This is the most common mistake in tutorials written before Gutenberg. Without it, your CPT gets the legacy editor, no REST API support, and no headless queryability.
  • Storing everything in post meta – Meta is a key-value store with no foreign key constraints and no indexing out of the box. If you are storing relational data or need to filter by it at scale, consider a custom table with proper indexing.

Plugin vs. Theme: Where Should CPT Code Live?

A common debate in WordPress development is whether CPT registrations belong in the theme or a plugin. The answer is clear from a data-persistence standpoint: always a plugin.

If you register a CPT in your theme’s functions.php and the client switches themes, the CPT is gone. All posts of that type are orphaned in the database. Queries return nothing. The front end breaks. By contrast, a plugin-registered CPT persists regardless of which theme is active. Even a deactivated plugin leaves the data in wp_posts and only stops surfacing it.

If you are building a site-specific solution that is guaranteed to use only one theme forever, you can make a pragmatic exception. But for any professional or client project, create a small my-project-cpts.php plugin and keep the CPT registrations there. If you are looking to speed up the full plugin development workflow, the AI-assisted WordPress development tools covered on AttoWP can cut the scaffolding time significantly.


Frequently Asked Questions

Can I change a post type slug after launch without breaking existing URLs?

Changing the rewrite slug changes the URLs of all posts of that type. Existing inbound links and Google-indexed URLs will return 404. If you must change a slug, set up 301 redirects from the old URL pattern to the new one using a plugin like Redirection or by writing redirect rules in Nginx/Apache config.

How do I make a CPT searchable by WordPress’s built-in search?

Set exclude_from_search => false in the registration arguments. By default, CPTs with public => true are already included in search. If your CPT is not appearing, check that publicly_queryable is also true and that no plugin is filtering the search query.

Can I use the Gutenberg block editor with a CPT that has no editor support?

If you omit 'editor' from the supports array, the Gutenberg canvas is removed. You can still have the Gutenberg-style sidebar (for categories, tags, featured image) by keeping show_in_rest => true, but the main content block area will not appear. This is useful for CPTs where the content is entirely driven by meta fields rather than a rich editor body.

Do I need CPTs for WooCommerce products or LearnDash lessons?

No – those plugins register their own CPTs (wc_product, sfwd-lessons, etc.) internally. You only register CPTs when you need a new content type that no existing plugin provides.

How many custom post types can I register on one WordPress site?

WordPress does not enforce a hard limit on the number of post types. Practically, each CPT adds entries to the wp_posts table and contributes to admin menu bloat and rewrite rule complexity. Sites with more than 10-15 CPTs often see slower page loads and harder-to-manage admin panels. If you find yourself registering many CPTs for closely related data, consider whether a single CPT with a distinguishing taxonomy or meta field is a better fit.


Wrapping Up

Custom post types are one of the most powerful features in WordPress and one of the most misunderstood by developers who come from other CMS backgrounds. Once you understand the relationship between post types, taxonomies, meta fields, and the REST API, you can model almost any data structure cleanly inside WordPress without a database migration.

The key things to take away from this tutorial: always set show_in_rest => true, always register CPTs in a plugin rather than a theme, use register_post_meta() to expose meta to the REST API, and consider ACF only when the native tools cannot give you the field types or editorial UI your project requires.

Custom post types turn WordPress from a blog engine into an application framework. The only limit is how well you model your data.


Build Better WordPress Applications

Custom post types are just one piece of the WordPress application development puzzle. If you are working on a complex WordPress project – multi-post-type architectures, headless setups, or custom editorial workflows – the AttoWP blog covers the tools, patterns, and real-world code that senior WordPress developers actually use.

Last modified: March 31, 2026

Close