You’re building a real estate platform. The client wants to list properties — each with a price, bedrooms, bathrooms, location, property type, and status. You could jam all of this into regular WordPress posts with categories and custom fields. Developers do it all the time. It works, technically, until you’re writing WP_Query calls that exclude posts, pages, and three other things just to get a clean list of properties. Then the client asks why their properties show up in the blog feed.
Custom Post Types (CPTs) exist to solve exactly this problem. They give your content its own identity in the database, its own admin menu, its own URL structure, its own templates, and its own query context. Paired with custom taxonomies, they form the backbone of almost every serious WordPress project.
This guide covers everything from registering a CPT to querying it, templating it, and wiring it up with ACF and the REST API. The running example throughout is a Properties post type — concrete enough to see the real decisions, generic enough to map to your own use case.
When to Use a Custom Post Type
The question isn’t “should this be a CPT?” — it’s “does this content have a distinct identity from posts?” If you’re managing portfolio projects, job listings, properties, events, testimonials, team members, or courses, the answer is almost always yes.
Use a CPT when:
- The content type has its own set of fields that don’t apply to posts
- You don’t want it mixed into the main blog query
- It needs its own archive URL (e.g.,
/properties/) - Editors need a separate admin menu section for it
- You’ll be querying it independently and frequently
Don’t reach for a CPT just because something is “different.” A page-level About section with a few extra fields is fine as a regular page with ACF. Reserve CPTs for content that is repeating, queryable, and distinct.
Registering a Custom Post Type
Register your CPT inside a function hooked to init. Never call register_post_type() directly at the top level of your plugin or functions file — it needs to run at the right time in WordPress’s loading sequence.
Here’s a full registration for a Properties post type with every important argument explained:
function cp_register_property_post_type() {
$labels = [
'name' => 'Properties',
'singular_name' => 'Property',
'menu_name' => 'Properties',
'add_new' => 'Add Property',
'add_new_item' => 'Add New Property',
'edit_item' => 'Edit Property',
'new_item' => 'New Property',
'view_item' => 'View Property',
'view_items' => 'View Properties',
'search_items' => 'Search Properties',
'not_found' => 'No properties found',
'not_found_in_trash' => 'No properties found in trash',
'all_items' => 'All Properties',
'archives' => 'Property Archives',
];
$args = [
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'query_var' => true,
'rewrite' => [ 'slug' => 'properties', 'with_front' => false ],
'capability_type' => 'post',
'has_archive' => true,
'hierarchical' => false,
'menu_position' => 5,
'menu_icon' => 'dashicons-building',
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ],
'show_in_rest' => true,
'rest_base' => 'properties',
'taxonomies' => [],
];
register_post_type( 'property', $args );
}
add_action( 'init', 'cp_register_property_post_type' );
Key Arguments Explained
public — Setting this to true is a shortcut that sets publicly_queryable, show_ui, show_in_nav_menus, and exclude_from_search to sensible defaults. You can override any of them individually afterward.
rewrite — Controls the URL slug. Setting with_front to false means your URLs won’t pick up a blog base prefix (e.g., you get /properties/beachfront-villa/ instead of /blog/properties/beachfront-villa/). Always set this explicitly.
capability_type — Using 'post' means property editing follows the same capability rules as posts (edit_posts, publish_posts, etc.). If you set this to 'property', WordPress generates a custom set of capabilities (edit_property, publish_properties, etc.) that no role has by default. You’d have to assign them manually. Start with 'post' unless you need granular role control.
has_archive — Set to true to enable the archive at /properties/, or pass a string to use a custom archive slug like 'property-listings'. Many developers skip this and then wonder why their archive template never loads.
hierarchical — false means flat structure (like posts). true enables parent/child relationships (like pages) and switches the admin edit screen to use a checkbox taxonomy input rather than tag-style input.
show_in_rest — Required for block editor support. If this is false, the block editor falls back to the classic editor for this post type. It also exposes the post type to the REST API.
supports — Controls which meta boxes appear on the edit screen. Common values: title, editor, thumbnail, excerpt, author, comments, revisions, page-attributes.
Flushing Rewrite Rules — The Mistake Everyone Makes Once
After registering a new CPT (or changing its slug), WordPress doesn’t automatically update its rewrite rules. You’ll get 404 errors on single property pages until you flush them.
During development, go to Settings > Permalinks and hit Save. That’s it — no changes needed, just the save action triggers a flush.
In a plugin, flush on activation only — never on every page load:
function cp_flush_rewrite_rules() {
cp_register_property_post_type();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'cp_flush_rewrite_rules' );
Calling flush_rewrite_rules() on every init is a common mistake that hits the database on every single request. Don’t do it.
Registering Custom Taxonomies
For the Properties post type, two taxonomies make sense: Property Type (hierarchical — Residential, Commercial, Industrial) and Amenities (non-hierarchical — Pool, Garage, Garden).
function cp_register_property_taxonomies() {
// Property Type — hierarchical (like categories)
register_taxonomy( 'property_type', 'property', [
'labels' => [
'name' => 'Property Types',
'singular_name' => 'Property Type',
'search_items' => 'Search Property Types',
'all_items' => 'All Property Types',
'parent_item' => 'Parent Type',
'parent_item_colon' => 'Parent Type:',
'edit_item' => 'Edit Property Type',
'update_item' => 'Update Property Type',
'add_new_item' => 'Add New Property Type',
'new_item_name' => 'New Property Type Name',
'menu_name' => 'Property Types',
],
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_admin_column' => true,
'query_var' => true,
'rewrite' => [ 'slug' => 'property-type' ],
'show_in_rest' => true,
]);
// Amenities — flat (like tags)
register_taxonomy( 'amenity', 'property', [
'labels' => [
'name' => 'Amenities',
'singular_name' => 'Amenity',
'search_items' => 'Search Amenities',
'popular_items' => 'Popular Amenities',
'all_items' => 'All Amenities',
'edit_item' => 'Edit Amenity',
'update_item' => 'Update Amenity',
'add_new_item' => 'Add New Amenity',
'new_item_name' => 'New Amenity Name',
'separate_items_with_commas' => 'Separate amenities with commas',
'add_or_remove_items' => 'Add or remove amenities',
'choose_from_most_used' => 'Choose from the most used amenities',
'menu_name' => 'Amenities',
],
'hierarchical' => false,
'public' => true,
'show_ui' => true,
'show_admin_column' => true,
'query_var' => true,
'rewrite' => [ 'slug' => 'amenity' ],
'show_in_rest' => true,
]);
}
add_action( 'init', 'cp_register_property_taxonomies' );
The second argument to register_taxonomy() is the object type — you can pass a string for one post type or an array to attach the taxonomy to multiple post types at once: [ 'property', 'project' ].
show_admin_column — Set this to true and WordPress adds a column to the Properties list table showing which terms are assigned. Small thing, big UX win for editors.
Connecting a Taxonomy to a Post Type After the Fact
If you’re adding a taxonomy to an existing post type (perhaps a CPT registered by a plugin), use register_taxonomy_for_object_type() instead of modifying the original registration:
add_action( 'init', function() {
register_taxonomy_for_object_type( 'amenity', 'property' );
});
The Template Hierarchy for CPTs
WordPress looks for templates in a specific order. For a Properties post type:
Single property page — WordPress checks for these templates in order:
single-property.phpsingle.phpsingular.phpindex.php
Properties archive — WordPress checks:
archive-property.phparchive.phpindex.php
Property Type taxonomy archive — WordPress checks:
taxonomy-property_type-{term-slug}.php(e.g.,taxonomy-property_type-residential.php)taxonomy-property_type.phptaxonomy.phparchive.phpindex.php
Create single-property.php and archive-property.php in your theme root. They work exactly like single.php and archive.php — use the Loop, call template parts, output fields the same way.
Querying CPTs with WP_Query
The main query on an archive page is already set up correctly when you have has_archive => true and you’re on the /properties/ URL. For custom queries elsewhere — a homepage featured properties section, a widget, a shortcode — use WP_Query directly.
$properties = new WP_Query([
'post_type' => 'property',
'posts_per_page' => 6,
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
'tax_query' => [
[
'taxonomy' => 'property_type',
'field' => 'slug',
'terms' => 'residential',
],
],
'meta_query' => [
[
'key' => 'property_price',
'value' => 500000,
'compare' => ' 'NUMERIC',
],
],
]);
if ( $properties->have_posts() ) {
while ( $properties->have_posts() ) {
$properties->the_post();
// output your property card here
get_template_part( 'template-parts/card', 'property' );
}
wp_reset_postdata();
}
Three things to always remember with custom WP_Query calls:
- Always call
wp_reset_postdata()after your loop. It resets the global$postobject. Skipping it causes subtle bugs in other parts of the page — sidebars, footers, and other template parts may pull data from the wrong post. - Set
post_status => 'publish'explicitly when querying outside the main loop context. It defaults correctly on front-end main queries, but explicit is safer in custom contexts. - Use
no_found_rows => truewhen you don’t need pagination — it skips theSQL_CALC_FOUND_ROWSquery and is faster.
Modifying the Main Query for CPT Archives
To change posts per page or add sorting to the main archive query, use pre_get_posts — not a secondary WP_Query:
add_action( 'pre_get_posts', function( WP_Query $query ) {
if ( ! is_admin() && $query->is_main_query() && is_post_type_archive( 'property' ) ) {
$query->set( 'posts_per_page', 12 );
$query->set( 'orderby', 'meta_value_num' );
$query->set( 'meta_key', 'property_price' );
$query->set( 'order', 'ASC' );
}
});
The ! is_admin() check is critical. Without it, this hook fires on admin list tables too, which breaks them.
ACF Integration with Custom Post Types
Advanced Custom Fields is the standard way to add structured data to CPTs. You register field groups and target them to your post type using ACF’s location rules.
If you’re using ACF’s PHP registration (preferable in production — keeps field definitions in version control rather than the database):
add_action( 'acf/init', function() {
acf_add_local_field_group([
'key' => 'group_property_details',
'title' => 'Property Details',
'fields' => [
[
'key' => 'field_property_price',
'label' => 'Price',
'name' => 'property_price',
'type' => 'number',
'instructions' => 'Enter price in USD without commas',
'required' => 1,
'min' => 0,
],
[
'key' => 'field_property_bedrooms',
'label' => 'Bedrooms',
'name' => 'property_bedrooms',
'type' => 'number',
'min' => 0,
'max' => 20,
],
[
'key' => 'field_property_bathrooms',
'label' => 'Bathrooms',
'name' => 'property_bathrooms',
'type' => 'number',
'min' => 0,
],
[
'key' => 'field_property_area',
'label' => 'Area (sq ft)',
'name' => 'property_area',
'type' => 'number',
'min' => 0,
],
[
'key' => 'field_property_status',
'label' => 'Listing Status',
'name' => 'property_status',
'type' => 'select',
'choices' => [
'available' => 'Available',
'under_offer' => 'Under Offer',
'sold' => 'Sold',
],
'default_value' => 'available',
'return_format' => 'value',
],
[
'key' => 'field_property_address',
'label' => 'Address',
'name' => 'property_address',
'type' => 'textarea',
'rows' => 3,
],
],
'location' => [
[
[
'param' => 'post_type',
'operator' => '==',
'value' => 'property',
],
],
],
'menu_order' => 0,
'position' => 'normal',
'style' => 'default',
'label_placement' => 'top',
'instruction_placement' => 'label',
]);
});
In your template, retrieve ACF fields with get_field():
$price = get_field( 'property_price' );
$bedrooms = get_field( 'property_bedrooms' );
$bathrooms = get_field( 'property_bathrooms' );
$status = get_field( 'property_status' );
if ( $price ) {
echo '<span class="property-price">$' . number_format( $price ) . '</span>';
}
REST API Exposure and Customization
Setting show_in_rest => true in your CPT registration makes it available at /wp-json/wp/v2/properties. This is required for the block editor to work with your post type. It also opens your CPT to headless setups, Gutenberg blocks that query posts, and external applications.
By default, ACF fields are not included in REST API responses. To expose specific fields:
add_action( 'rest_api_init', function() {
$fields = [
'property_price' => 'integer',
'property_bedrooms' => 'integer',
'property_bathrooms' => 'number',
'property_area' => 'number',
'property_status' => 'string',
];
foreach ( $fields as $field_name => $field_type ) {
register_rest_field( 'property', $field_name, [
'get_callback' => function( $post ) use ( $field_name ) {
return get_field( $field_name, $post['id'] );
},
'schema' => [
'type' => $field_type,
'context' => [ 'view', 'edit' ],
],
]);
}
});
Now a GET /wp-json/wp/v2/properties request returns each property with property_price, property_bedrooms, and other fields included directly in the response object.
If you want to restrict REST access to authenticated users only:
add_filter( 'rest_property_query', function( $args, $request ) {
if ( ! current_user_can( 'read' ) ) {
return new WP_Error( 'rest_forbidden', 'You do not have permission.', [ 'status' => 401 ] );
}
return $args;
}, 10, 2 );
Block Editor Integration and Template Locking
With show_in_rest => true set, your CPT works in the block editor. You can go further and define a default block template for new property posts — pre-loaded with the right blocks in the right structure, optionally locked so editors can’t rearrange or delete structural blocks.
add_action( 'init', function() {
$post_type_object = get_post_type_object( 'property' );
$post_type_object->template = [
[ 'core/image', [
'align' => 'wide',
]],
[ 'core/heading', [
'level' => 2,
'placeholder' => 'Property headline...',
]],
[ 'core/paragraph', [
'placeholder' => 'Describe the property...',
]],
[ 'core/columns', [], [
[ 'core/column', [], [
[ 'core/paragraph', [ 'placeholder' => 'Location details...' ] ],
]],
[ 'core/column', [], [
[ 'core/paragraph', [ 'placeholder' => 'Key features...' ] ],
]],
]],
];
// 'all' locks both insert and move. Use 'insert' to allow reordering but not adding/removing.
$post_type_object->template_lock = 'all';
});
Template locking is useful when you need consistent structure across all entries — particularly for CPTs where the content layout feeds into a design system. Editors fill in the content; they can’t break the structure.
Set template_lock to 'insert' if you want to allow reordering blocks but prevent adding or deleting them. Set it to false to disable locking (default).
Common Mistakes and How to Avoid Them
Forgetting to flush rewrite rules after registration. New CPT, changed slug, 404 errors. Go to Settings > Permalinks and save. If you’re deploying, add a flush on plugin activation as shown earlier. This trips up even experienced developers when they’re moving fast.
Setting capability_type to the post type name without assigning capabilities. If you use 'capability_type' => 'property', WordPress generates a custom set of capabilities that no user role has by default. Editors and even administrators won’t be able to publish properties until you explicitly add those capabilities to roles using add_role() or get_role()->add_cap(). Stick with 'post' unless you have a specific reason for custom capabilities.
Not setting has_archive => true. If this is missing, /properties/ returns a 404 and your archive-property.php template never loads. WordPress has no reason to generate an archive URL for the post type. Always set this explicitly when you want an archive page.
Not setting show_in_rest => true. Your post type will silently fall back to the classic editor. The block editor requires this. REST API integrations and headless setups require this. It should be true for any public-facing CPT.
Calling flush_rewrite_rules() on every request. Some tutorials put this in functions.php without a hook or condition. Every page load hits the database to rebuild rewrite rules. At scale this causes serious performance issues. Flush only on plugin activation or a one-time admin action.
Skipping wp_reset_postdata() after a secondary loop. If you run a custom WP_Query and don’t reset post data, the global $post gets stuck on whatever the last post in your query was. Template functions like the_title(), get_the_permalink(), and get_field() (when called without an explicit post ID) will return data from the wrong post for the rest of the page render.
Registering CPTs and taxonomies inside a theme’s functions.php without a plugin. When the theme changes, content disappears from the admin — not from the database, but from the UI. CPT and taxonomy registrations belong in a site-specific plugin or a must-use plugin, not in a theme. The theme controls presentation; content architecture belongs in the application layer.
Putting It All Together
A clean plugin structure for the Properties CPT looks like this:
properties-plugin/
├── properties-plugin.php // Plugin header, includes
├── includes/
│ ├── post-type.php // register_post_type()
│ ├── taxonomies.php // register_taxonomy() calls
│ ├── acf-fields.php // acf_add_local_field_group()
│ ├── rest-api.php // register_rest_field() calls
│ └── query-modifications.php // pre_get_posts hooks
└── templates/ // Optional — if using plugin-level templates
Each concern is isolated. The post type registration doesn’t need to know about ACF fields. The REST API extensions don’t need to know about taxonomy registration. When something breaks — and something always breaks — you know exactly which file to open.
CPTs and taxonomies are one of those WordPress features where the initial setup is fast (fifteen minutes to have a working post type) but the depth is real. Query customization, template hierarchy, REST exposure, capability management, block editor templates — each one adds a layer. The code This guide handles all of it in a way that holds up in production, not just in a localhost demo.
If you’re using this as a starting point for a client project, the Properties example maps cleanly to portfolios (project), agencies (service), job boards (job_listing), and directories (business). The registration pattern is identical — swap the labels, the slug, and the ACF fields.