WordPress Custom Post Types Explained
A custom post type is one string in the posts table plus a registration call. The arguments you pass decide whether it gets URLs, an archive, REST access and its own templates — and most 404s come from three of them.
What a custom post type actually is: a value in the post_type column of the wp_posts table. Posts, pages, attachments, revisions and menu items are all post types already. Registering your own adds no tables and no new storage model — it tells WordPress that a value like event exists, and hands it an admin screen, URLs and query behaviour.
That framing settles most design questions. If your content has a title, a body, an author and a date, it is a post type. If it is a handful of settings, it is an option. If it is one extra field on an existing post, it is post meta.
Register it in a plugin, not the theme
Post types belong to the site, not to its design. Register them in a small site-specific plugin — a single file in wp-content/plugins/ — so that switching themes never hides your content.
<?php
/*
Plugin Name: Site Content Types
Description: Registers the content types this site owns.
Version: 1.0.0
*/
add_action('init', function (): void {
register_post_type('event', [
'labels' => [
'name' => 'Events',
'singular_name' => 'Event',
'add_new_item' => 'Add New Event',
],
'public' => true,
'has_archive' => true,
'menu_icon' => 'dashicons-calendar-alt',
'menu_position' => 20,
'supports' => ['title', 'editor', 'thumbnail', 'excerpt', 'revisions'],
'show_in_rest' => true,
'rewrite' => ['slug' => 'events', 'with_front' => false],
]);
});
Content registered by a theme does not vanish when the theme changes — the rows stay in the database — but the admin screens and the front-end URLs do, which looks identical to data loss to whoever reports it.
The arguments that decide everything
| Argument | What it controls | Getting it wrong looks like |
|---|---|---|
public |
Whether the type is queryable and visible at all | The type exists but has no URLs and no admin menu |
has_archive |
Whether /events/ lists them |
A single event loads, the listing 404s |
rewrite['slug'] |
The URL segment | Collides with a page of the same name |
show_in_rest |
REST API and the block editor | Classic editor appears instead of blocks |
supports |
Which metaboxes the editor shows | No featured image, no excerpt, no revisions |
capability_type |
Which capabilities gate it | Editors can or cannot manage it, unexpectedly |
menu_position |
Where it sits in the sidebar | Cosmetic only |
show_in_rest is the one that surprises people. It is false by default, and without it the block editor cannot load the type at all, so WordPress quietly serves the classic editor instead. It also gates the type's availability to the Query Loop block and to anything reading the WordPress REST API.
capability_type defaults to post, which means anyone who can edit posts can edit events. If the type needs its own permissions, set capability_type and map_meta_cap, then grant the resulting capabilities to roles — the mechanics are in WordPress user roles explained.
Flush rewrite rules once, not on every load
Rewrite rules are cached in an option. A newly registered type has no rules until they are regenerated, which is why a brand-new post type 404s on the front end while looking perfectly healthy in the admin.
Fix it by visiting Settings → Permalinks and clicking Save Changes, or:
wp rewrite flush --hard
Never call flush_rewrite_rules() on init. It rewrites an option on every single request — a real, measurable cost on every page load. Flush on plugin activation instead:
register_activation_hook(__FILE__, function (): void {
// Register the type first so its rules exist to be flushed.
do_action('init');
flush_rewrite_rules();
});
If the 404 survives a flush, the rewrite slug is colliding with something. A Page called "Events" and a post type with slug events cannot both own /events/; one of them has to move. The full diagnostic sequence is in WordPress permalinks not working and how to fix WordPress 404 errors.
Taxonomies belong with the type
A post type usually needs its own categories. Register the taxonomy alongside it, on the same hook:
register_taxonomy('event_type', ['event'], [
'public' => true,
'hierarchical' => true,
'show_in_rest' => true,
'rewrite' => ['slug' => 'event-type'],
]);
hierarchical is the category-versus-tag switch: true gives you parent/child checkboxes, false gives a free-text field. Reusing the built-in category and post_tag taxonomies is allowed and sometimes right, but it mixes your events into the blog's archives — decide that deliberately rather than by default. How tags behave once attached is covered in adding tags to a blog post.
Templates for a custom type
In a classic theme, the hierarchy adds two files per type:
single-event.php → one event
archive-event.php → the /events/ listing
taxonomy-event_type.php → one term archive
Both fall back to single.php and archive.php when absent. Put them in a child theme so a parent update cannot remove them.
In a block theme the same names live in templates/ with an .html extension — templates/single-event.html, templates/archive-event.html — and can be created directly in the Site Editor, which writes them to the database until you export.
Plugin or code?
A registration plugin with a UI is a legitimate choice for a site whose owner will add types later without a developer. The trade-off is real, though: the definition then lives in the database, it is invisible to version control, and deactivating the plugin unregisters every type at once, taking the URLs and admin screens with it.
Code is the better default when a developer maintains the site. Use a UI plugin when the client genuinely needs to self-serve, and export its configuration to a file as part of your backups either way.
Common mistakes
- Registering later than
init. The type simply does not exist; nothing errors. - A slug longer than 20 characters.
post_typeis a 20-character column and registration fails silently past it. - Reusing a reserved name such as
post,page,attachment,revision,action,orderortheme. Prefix your types. - Forgetting
show_in_rest, then debugging "the block editor is broken". - Renaming the rewrite slug after launch without adding redirects for the old URLs.
- Turning off
publicto hide a type that still needs an admin screen. Usepublicly_queryableandexclude_from_searchfor that instead.
Frequently asked
- No. Register it in a small site-specific plugin. Content registered by a theme disappears from the admin the moment the theme is switched, even though the rows are still in the database.
- Almost always unflushed rewrite rules. Save Settings → Permalinks once after registering the type. If it persists, the rewrite slug collides with an existing page or the type was registered with public set to false.
- Only if show_in_rest is true and the supports array includes editor. Without REST support the type silently falls back to the classic editor.