Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 31 of 45

Custom post types

Register your own content types in WordPress with register_post_type(), set labels, supports, rewrite and REST options, and use them in queries and templates.

15 minHooks and pluginsUpdated 23 October 2026

By the end of this lessonCreate a custom post type such as Recipe or Event in a plugin, flush rewrite rules correctly, and display its content.

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
<?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' );
bash
wp plugin activate bakery-recipes
result
Plugin '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:

bash
wp post-type list --fields=name,label,public,has_archive --name=recipe
result
+--------+---------+--------+-------------+
| 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:

bash
wp rewrite flush
result
Success: Rewrite rules flushed.

Using the new type

Create two recipes with WP-CLI and look at them:

bash
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=asc
result
Success: 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:

http
GET /recipes/
result
Rye bread
Sourdough loaf
http
GET /recipes/sourdough-loaf/
result
Sourdough loaf

Block 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
<?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";
}
result
5: Rye bread
4: Sourdough loaf

Without 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:

http
GET /wp-json/wp/v2/recipe?_fields=id,slug,title.rendered&orderby=id&order=asc
result
[{"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 as post, page or action.
  • 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
php
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.

Check yourself

1. On which hook should you call register_post_type()?
2. What happens if you leave 'show_in_rest' out of the arguments?
3. Why not call flush_rewrite_rules() on every page load?
4. Why put post types in a plugin rather than a theme?