Beyond posts and pages
WordPress has two built-in content types: posts (a stream of news) and pages (static). Many sites need other kinds of content with their own fields and address: recipes, events, team members, products. A custom post type gives each its own menu, list, editor screen and URL structure.
Internally, they are all rows in the same wp_posts table, with a post_type column that says which kind.
Registering a type
Create a plugin (see Writing your first plugin) and register the type on init:
<?php
/**
* Plugin Name: Bakery Recipes
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function bakery_register_recipe_type() {
register_post_type( 'recipe', [
'labels' => [
'name' => 'Recipes',
'singular_name' => 'Recipe',
'add_new_item' => 'Add new recipe',
'edit_item' => 'Edit recipe',
'all_items' => 'All recipes',
],
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-carrot',
'supports' => [ 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ],
'rewrite' => [ 'slug' => 'recipes' ],
'show_in_rest' => true, // block editor and REST API
] );
}
add_action( 'init', 'bakery_register_recipe_type' );
// Rewrite rules (the address patterns) must be rebuilt once when the type first appears.
register_activation_hook( __FILE__, function () {
bakery_register_recipe_type();
flush_rewrite_rules();
} );
register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );wp plugin activate bakery-recipesPlugin 'bakery-recipes' activated.
Success: Activated 1 of 1 plugins.The main arguments:
| Argument | Purpose |
|---|---|
labels |
the words used in the admin; at least name and singular_name |
public |
whether the type has its own front-end pages and admin menu |
has_archive |
gives you a list page at /recipes/ |
supports |
which editor boxes to show: title, editor, author, thumbnail, excerpt, comments, revisions, page-attributes, custom-fields |
rewrite |
the address slug, here /recipes/… |
show_in_rest |
required for the block editor and the REST API |
hierarchical |
true for page-like types with parents |
taxonomies |
existing taxonomies to attach (see Custom taxonomies) |
Registered types:
wp post-type list --fields=name,label,public,has_archive --name=recipe+--------+---------+--------+-------------+
| name | label | public | has_archive |
+--------+---------+--------+-------------+
| recipe | Recipes | 1 | 1 |
+--------+---------+--------+-------------+Rewrite rules: flush once
The addresses (such as /recipes/sourdough/) come from rewrite rules that WordPress stores. After registering a new type they must be rebuilt, otherwise the new URLs show a 404. That is what flush_rewrite_rules() does. It is slow, so run it only on activation and deactivation, never on every init. When you are developing and change the slug, visit Settings → Permalinks and press Save, or run:
wp rewrite flushSuccess: Rewrite rules flushed.Using the new type
Create two recipes with WP-CLI and look at them:
wp post create --post_type=recipe --post_title="Sourdough loaf" --post_content="Mix, rest, bake." --post_status=publish
wp post create --post_type=recipe --post_title="Rye bread" --post_content="Rye flour, water, salt." --post_status=publish
wp post list --post_type=recipe --fields=ID,post_title,post_status --orderby=ID --order=ascSuccess: Created post 4.
Success: Created post 5.
+----+----------------+-------------+
| ID | post_title | post_status |
+----+----------------+-------------+
| 4 | Sourdough loaf | publish |
| 5 | Rye bread | publish |
+----+----------------+-------------+The dashboard now has a Recipes menu with its own editor. On the front end:
GET /recipes/Rye bread
Sourdough loafGET /recipes/sourdough-loaf/Sourdough loafBlock themes display these with the standard single and archive templates, and you can add single-recipe.html or archive-recipe.html for a special layout (see the template hierarchy). Classic themes use single-recipe.php and archive-recipe.php.
Querying the type
<?php
$query = new WP_Query( [
'post_type' => 'recipe',
'posts_per_page' => 10,
'orderby' => 'title',
'order' => 'ASC',
'no_found_rows' => true,
] );
foreach ( $query->posts as $recipe ) {
echo $recipe->ID, ': ', $recipe->post_title, "\n";
}5: Rye bread
4: Sourdough loafWithout post_type, WP_Query returns only posts. To include recipes in the blog's main Loop, use the pre_get_posts hook and set post_type to an array (see Custom queries with WP_Query).
Custom post types and the REST API
Because of show_in_rest, the type is also available as JSON:
GET /wp-json/wp/v2/recipe?_fields=id,slug,title.rendered&orderby=id&order=asc[{"id":4,"slug":"sourdough-loaf","title":{"rendered":"Sourdough loaf"}},{"id":5,"slug":"rye-bread","title":{"rendered":"Rye bread"}}]The REST API lesson goes deeper.
Common mistakes
- Registering in the theme, so recipes vanish when the theme changes.
- Forgetting
show_in_rest, which leaves you on the classic editor. - Flushing rewrite rules on every request.
- Type names that are too long or clash: keys are limited to 20 characters, lowercase, with no spaces; prefix them (
bakery_recipe) in plugins you share, and never use WordPress reserved names such aspost,pageoraction. - Changing the key later: the key is stored in the database for every post, so changing it orphans existing content.
Exercise
Register a second type, event, with archive at /events/, supporting title, editor and thumbnail, and icon dashicons-calendar-alt. What must you do after adding it?
Show one possible answer
add_action( 'init', function () {
register_post_type( 'event', [
'labels' => [ 'name' => 'Events', 'singular_name' => 'Event' ],
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-calendar-alt',
'supports' => [ 'title', 'editor', 'thumbnail' ],
'rewrite' => [ 'slug' => 'events' ],
'show_in_rest' => true,
] );
} );After adding the code, flush the rewrite rules once (Settings → Permalinks → Save, or wp rewrite flush), otherwise /events/ returns a 404.
Key points
- Posts and pages are post types; register_post_type() on the init hook adds your own, such as recipe or event.
- Key arguments: labels, public, has_archive, supports, rewrite, show_in_rest (needed for the block editor) and menu_icon.
- Register post types in a plugin, not a theme, and flush rewrite rules only on activation and deactivation.
- Query your type with WP_Query 'post_type', and give it templates such as single-recipe.php or archive-recipe.php.