When content goes beyond just “Posts” and “Pages”—such as products, case studies, job listings, or property rentals—it’s time to use Custom Post Types (CPT). Trying to force this into standard posts with categories might work short-term, but it becomes increasingly cumbersome over the long run.
When to Use CPT
| Scenario | Use CPT | Use Posts + Categories |
|---|---|---|
| Content structure is identical to posts | Unnecessary | Appropriate |
| Has its own fields (price, specs) | Appropriate | 勉强 |
| Requires independent archives and lists | Appropriate | Inappropriate |
| High volume and long-term maintenance | Appropriate | Becomes chaotic |
Criterion: If this type of content requires “its own set of fields” or “its own archive page,” use a CPT.
Registering a CPT
<?php
add_action('init', function () {
register_post_type('product', [
'labels' => [
'name' => 'Products',
'singular_name' => 'Product',
'add_new_item' => 'Add New Product',
],
'public' => true,
'has_archive' => true, // Has an archive page
'show_in_rest' => true, // Supports Gutenberg and REST API
'supports' => ['title', 'editor', 'thumbnail', 'excerpt'],
'menu_icon' => 'dashicons-cart',
'rewrite' => ['slug' => 'products'],
]);
});
Key parameter explanations:
| Parameter | Purpose | Note |
|---|---|---|
public |
Whether visible on the frontend | If false, only visible in admin |
has_archive |
Whether an archive page exists | Set to true when a list is needed |
show_in_rest |
Gutenberg and REST support | Recommended: true |
supports |
Supported features | Enable as needed |
rewrite.slug |
URL prefix | Affects link structure |
# After registering, refresh permalink rules, otherwise you'll get 404s on the frontend
wp rewrite flush
# Or go to Admin > Settings > Permalinks and click Save once
“Visible in backend but 404 on frontend” is almost always due to forgetting to refresh rewrite rules. This is the most common issue with CPTs.
Custom Fields
Native Approach
<?php
// Saving (hooked to save_post)
add_action('save_post_product', function ($post_id) {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (!current_user_can('edit_post', $post_id)) return;
if (isset($_POST['_price'])) {
update_post_meta($post_id, '_price', sanitize_text_field($_POST['_price']));
}
});
// Reading
$price = get_post_meta(get_the_ID(), '_price', true);
Meta keys starting with an underscore (like _price) will not appear in the default custom fields box in the backend. This is a convention suitable for programmatically managed fields.
Using the ACF Plugin
ACF (Advanced Custom Fields) visualizes the workflow of “defining fields → entering data in the backend → outputting on the frontend,” saving you from writing lots of boilerplate code:
- Create a field group in the backend and specify which CPT it applies to.
- Fill in the fields when editing content.
- Retrieve values on the frontend using
get_field().
<?php
// Retrieving value with ACF
$price = get_field('price');
if ($price) {
echo esc_html($price);
}
// Inside a loop
while (have_posts()) {
the_post();
echo get_field('price');
}
<?php
// Filtering by field (ACF values are stored in postmeta)
$args = [
'post_type' => 'product',
'posts_per_page' => 10,
'meta_query' => [
[
'key' => 'price',
'value' => 1000,
'compare' => '>=',
'type' => 'NUMERIC', // Must specify type for numeric comparison
],
],
];
$query = new WP_Query($args);
You must include 'type' => 'NUMERIC' in meta_query for numeric comparisons; otherwise, it compares as strings, meaning 9 would be considered greater than 10.
Template Files
| Template | Purpose |
|---|---|
archive-product.php |
Archive/List Page |
single-product.php |
Single Detail Page |
<?php
// archive-product.php —— List
get_header();
if (have_posts()) {
while (have_posts()) {
the_post();
the_title('<h2>', '</h2>');
echo '<p>Price: ' . esc_html(get_field('price')) . '</p>';
}
the_posts_pagination();
}
get_footer();
The template naming convention is archive-{post_type}.php and single-{post_type}.php. If these templates are not created, WordPress falls back to the default archive/single templates.
Taxonomies (Optional)
<?php
add_action('init', function () {
register_taxonomy('product_cat', 'product', [
'labels' => ['name' => 'Product Categories'],
'public' => true,
'hierarchical' => true, // true acts like categories, false like tags
'show_in_rest' => true,
'rewrite' => ['slug' => 'product-cat'],
]);
});
Decision Tree
Use CPT
├─ Register via register_post_type (place in a custom plugin, not the parent theme)
├─ Must refresh rewrite rules after registration ← Main cause of frontend 404s
├─ Fields
│ ├─ Simple, program-managed → Native post_meta
│ └─ Need visual backend entry → ACF
├─ Queries
│ ├─ WP_Query + post_type
│ └─ meta_query numeric comparisons require 'type' => NUMERIC
└─ Templates
├─ archive-{type}.php
└─ single-{type}.php
Scenario Analysis: Using Posts for Product Introductions Leads to Chaos
Symptom: A site treats product introductions as regular posts, distinguishing them via categories.
After six months, the content becomes completely mixed: products appear in the general posts list, and the product archive structure becomes unmanageable.
- Identify the root cause of the chaos → Different types of content share the same structure and templates,
relying solely on categories for distinction, which is unsuitable for defining “types.” - List the fields that truly need differentiation → Products have prices, parameters, and specs,
which posts do not; these fields have no fixed storage location within the post system. - Adopt independent types to carry the content → Register a new content type
with its own list, templates, and field sets. - Migrate existing content over → Migration must handle field mapping simultaneously,
preserving original publish dates and URL structures. - Define exclusive fields for the new type → Use custom fields to hold structured information,
avoiding stuffing HTML directly into the post content. - Confirm URLs and indexing are unaffected → After migration, verify individually that old URLs remain accessible.
# View registered types and existing fields
wp post-type list --allow-root
wp post list --post_type=product-type --fields=ID,post_title,post_date --allow-root
# Batch rewrite, first use dry-run to confirm impact scope
wp search-replace 'old-path' 'new-path' --dry-run --report-changed-only --allow-root
# Verify old URLs are still valid after migration
curl -sS -o /dev/null -w '%{http_code}n' https://example.com/old-path/
Root Cause: Using categories to distinguish fundamentally different content types.
Categories are suitable for horizontal classification, not for asserting “this is a different thing.” As scale increases, control is inevitably lost.
Two key takeaways:
- To decide if a new type is needed, check if it has exclusive fields.
Structured information like price, parameters, and specs should not be squeezed into generic content. - The focus during migration is two things: field mapping and URL accessibility.
The former determines data integrity; the latter directly impacts accumulated external links and indexing.
8 Common Pitfalls
- Not refreshing rewrite rules after registration: Results in frontend 404s.
- Placing flush inside init to execute every time: Wastes performance.
- Writing code in the parent theme: CPT disappears when switching themes (data remains but becomes invisible).
- Omitting ‘type’ in meta_query numeric comparisons: String comparison leads to incorrect results.
- Forgetting show_in_rest: Gutenberg and REST API become unusable.
- Outputting fields without escaping: XSS risk; use
esc_html. - Not enabling has_archive but wanting a list page: Archive page does not exist.
- Slug conflicts with other types: URL rules overwrite each other.
Real Customer Feedback (From Cloud Market Reviews and Inquiries for Similar Services)
The following excerpts are taken from real buyer reviews and inquiries for similar operations services on Alibaba Cloud / Tencent Cloud / Huawei Cloud Cloud Markets (service providers include our store and peers, provided for reference only, not fictional tickets from this site):
10***09 2024-03-18 5.0 Convenient and stable | Clean interface | Comprehensive after-sales service (User did not fill in comment content)