Adding a custom post type to WordPress without a plugin is simpler than most tutorials make it look. WordPress gives you register_post_type(), a core function that lets you define any content type you need: portfolio items, team members, testimonials, products, whatever fits your site. The code lives in your theme’s functions.php or a small site-specific plugin, and it takes about 20 lines. This guide walks through every step: setting up a custom post type WordPress install can use out of the box, enabling the REST API, attaching a custom taxonomy, writing the right theme templates, and verifying everything works in Gutenberg.
What a Custom Post Type in WordPress Actually Is
WordPress ships with several built-in post types: post, page, attachment, revision, and a few internal ones. Every piece of content you publish lives in the wp_posts table with a post_type column that identifies what it is.
A custom post type (CPT) is simply a new value for that column, registered in PHP so WordPress knows how to handle it. Once registered, WordPress automatically creates admin menus, list tables, an edit screen, archive pages, and (if you ask it to) REST API endpoints for your new content type.
You do not need a plugin for any of this. The register_post_type() function has been part of WordPress core since version 2.9. Plugin-based solutions like CPT UI are useful for people who prefer a GUI, but they call the exact same function under the hood. Doing it yourself keeps your setup lean and gives you full control over every argument.
Where to Put the Code
You have two options for where to register your post type.
Option 1: Site-specific plugin. Create a file at wp-content/plugins/my-cpts/my-cpts.php with a plugin header. This is the better choice if you want the post type to survive a theme switch.
<?php
/**
* Plugin Name: My Custom Post Types
* Description: Registers custom post types for this site.
* Version: 1.0
*/
Option 2: functions.php. Add the registration code to your active theme’s functions.php. Fine for prototyping, but if you ever switch themes the registered post type disappears along with the theme.
For production sites, the site-specific plugin approach is better. Either way, the registration code itself is identical.
Register a Custom Post Type in WordPress with register_post_type
The function signature is register_post_type( $post_type, $args ). You hook it to init. Here is a complete, production-ready example that registers a “Book” post type.
add_action( 'init', 'mySite_register_book_cpt' );
function mySite_register_book_cpt() {
$labels = array(
'name' => 'Books',
'singular_name' => 'Book',
'menu_name' => 'Books',
'add_new' => 'Add New',
'add_new_item' => 'Add New Book',
'edit_item' => 'Edit Book',
'new_item' => 'New Book',
'view_item' => 'View Book',
'view_items' => 'View Books',
'search_items' => 'Search Books',
'not_found' => 'No books found.',
'not_found_in_trash' => 'No books found in trash.',
'all_items' => 'All Books',
'archives' => 'Book Archives',
'attributes' => 'Book Attributes',
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'query_var' => true,
'rewrite' => array( 'slug' => 'books' ),
'capability_type' => 'post',
'has_archive' => true,
'hierarchical' => false,
'menu_position' => 5,
'menu_icon' => 'dashicons-book-alt',
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ),
'show_in_rest' => true,
'rest_base' => 'books',
);
register_post_type( 'book', $args );
}
Save and reload your WordPress admin. A “Books” menu item appears in the sidebar.
Understanding the Key Arguments
Most of the arguments have sensible defaults, but a few deserve attention.
public is a shortcut that sets publicly_queryable, show_ui, show_in_nav_menus, and exclude_from_search all at once. Set it to true if you want the post type visible on the front end.
rewrite controls the URL slug. Setting 'slug' => 'books' gives you URLs like yoursite.com/books/my-book-title/. If you want a nested structure such as /library/books/, set the slug to library/books. After changing this argument, always flush rewrite rules by visiting Settings > Permalinks in the admin.
has_archive set to true creates an archive page at yoursite.com/books/. You can also pass a string instead of true to use a different archive slug, like 'has_archive' => 'all-books'.
hierarchical set to true makes the post type work like pages (parent/child relationships, page order). Set to false for flat content like posts.
supports tells WordPress which meta boxes and fields to show in the edit screen. Common values: title, editor, thumbnail, excerpt, custom-fields, comments, revisions, author, page-attributes.
menu_icon accepts any Dashicons slug (like dashicons-book-alt) or a URL to a custom icon. Browse available Dashicons at developer.wordpress.org/resource/dashicons.
Enabling the REST API with show_in_rest
This is the argument that makes your custom post type work in the Gutenberg editor and available at the WordPress REST API. Without 'show_in_rest' => true, Gutenberg will not be able to save content for this post type, and the REST endpoint will not exist.
The two REST-related arguments are:
'show_in_rest' => true,
'rest_base' => 'books',
rest_base is optional. If omitted, the REST API uses the post type slug (book). Setting it explicitly to books gives you a cleaner plural URL.
With those in place, your posts are accessible at:
GET /wp-json/wp/v2/books // list all books
GET /wp-json/wp/v2/books/<id> // single book
POST /wp-json/wp/v2/books // create (authenticated)
You can verify this in a browser or with curl:
curl https://yoursite.com/wp-json/wp/v2/books
A JSON array comes back with your published books. If the endpoint returns a 404, either show_in_rest is not set to true, or you need to flush permalinks.
If you want to expose or restrict specific fields in the REST response, you can add a rest_controller_class argument to point at a custom controller, or filter specific fields via the register_rest_field() function. For most use cases the defaults are fine. If you are building a headless front end, check out the guide to headless WordPress with Next.js for how to consume these endpoints from a decoupled frontend.
Flushing Rewrite Rules After Registration
Whenever you add or change a custom post type, the rewrite rules stored in the database need to be regenerated. WordPress does not do this automatically on every page load (that would be slow).
The quick way: go to Settings > Permalinks in your WordPress admin and click Save Changes. That’s it, no changes needed, just saving triggers a flush.
The code way, useful during plugin activation:
register_activation_hook( __FILE__, 'mySite_flush_rewrite_rules' );
function mySite_flush_rewrite_rules() {
mySite_register_book_cpt();
flush_rewrite_rules();
}
Calling flush_rewrite_rules() on every page load is an anti-pattern. Only call it on plugin activation/deactivation or when a setting changes that affects URL structure.
Registering a Custom Taxonomy for Your Post Type
A custom taxonomy works the same way: hook to init, call register_taxonomy(), and attach it to your post type. Here is a “Genre” taxonomy for the Books post type.
add_action( 'init', 'mySite_register_genre_taxonomy' );
function mySite_register_genre_taxonomy() {
$labels = array(
'name' => 'Genres',
'singular_name' => 'Genre',
'search_items' => 'Search Genres',
'all_items' => 'All Genres',
'edit_item' => 'Edit Genre',
'update_item' => 'Update Genre',
'add_new_item' => 'Add New Genre',
'new_item_name' => 'New Genre Name',
'menu_name' => 'Genres',
'not_found' => 'No genres found.',
'back_to_items' => 'Back to Genres',
);
$args = array(
'hierarchical' => true,
'labels' => $labels,
'show_ui' => true,
'show_admin_column' => true,
'query_var' => true,
'rewrite' => array( 'slug' => 'genre' ),
'show_in_rest' => true,
);
register_taxonomy( 'genre', array( 'book' ), $args );
}
The second argument to register_taxonomy() is the post type it attaches to. Pass an array if the taxonomy should appear on multiple post types.
Setting 'hierarchical' => true gives you a category-style meta box with checkboxes and parent/child nesting. Setting it to false gives you a tag-style meta box with a text input.
Setting 'show_in_rest' => true exposes the taxonomy via /wp-json/wp/v2/genre and makes it available in the Gutenberg sidebar.
You can also attach a taxonomy to a post type after both are registered using:
register_taxonomy_for_object_type( 'genre', 'book' );
Useful if you are integrating with a third-party CPT you did not register yourself.
Classic Theme Templates: single-{cpt}.php and archive-{cpt}.php
For classic (non-block) themes, WordPress follows a template hierarchy to decide which PHP file renders a request. For a custom post type named book, the lookup order is:
For single posts:
single-book.phpin your themesingle.phpsingular.phpindex.php
For the archive:
archive-book.phpin your themearchive.phpindex.php
Create single-book.php in your theme folder:
<?php get_header(); ?>
<div class="site-main">
<?php while ( have_posts() ) : the_post(); ?>
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
<header class="entry-header">
<h1 class="entry-title"><?php the_title(); ?></h1>
<?php if ( has_post_thumbnail() ) : ?>
<div class="book-cover">
<?php the_post_thumbnail( 'large' ); ?>
</div>
<?php endif; ?>
</header>
<div class="entry-content">
<?php the_content(); ?>
</div>
<footer class="entry-footer">
<?php
$genres = get_the_terms( get_the_ID(), 'genre' );
if ( $genres && ! is_wp_error( $genres ) ) {
$genre_names = wp_list_pluck( $genres, 'name' );
echo '<p class="book-genres">Genres: ' . esc_html( implode( ', ', $genre_names ) ) . '</p>';
}
?>
</footer>
</article>
<?php endwhile; ?>
</div>
<?php get_footer(); ?>
Create archive-book.php in the same theme folder:
<?php get_header(); ?>
<div class="site-main">
<header class="page-header">
<h1 class="page-title"><?php post_type_archive_title(); ?></h1>
</header>
<div class="book-grid">
<?php while ( have_posts() ) : the_post(); ?>
<article id="post-<?php the_ID(); ?>" <?php post_class( 'book-card' ); ?>>
<a href="<?php the_permalink(); ?>">
<?php the_post_thumbnail( 'medium' ); ?>
<h2 class="book-title"><?php the_title(); ?></h2>
</a>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
</div>
<?php the_posts_pagination(); ?>
</div>
<?php get_footer(); ?>
Block Theme Templates: Full Site Editing
If you are using a block theme (one with a theme.json file and templates stored as HTML files), the approach is different. Instead of PHP files, you create HTML template files in the templates/ folder of your theme.
Create templates/single-book.html:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-featured-image /-->
<!-- wp:post-content /-->
<!-- wp:post-terms {"term":"genre"} /-->
</main>
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
Create templates/archive-book.html:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<main class="wp-block-group">
<!-- wp:query-title {"type":"archive"} /-->
<!-- wp:query {"query":{"postType":"book","perPage":10}} -->
<!-- wp:post-template -->
<!-- wp:post-title {"isLink":true} /-->
<!-- wp:post-featured-image {"isLink":true} /-->
<!-- wp:post-excerpt /-->
<!-- /wp:post-template -->
<!-- wp:query-pagination /-->
<!-- /wp:query -->
</main>
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
Once these files are in the templates/ directory, WordPress will use them automatically. You can also create and edit these templates directly from the Site Editor (Appearance > Editor > Templates). If you add a template through the editor, WordPress saves it as a post in the database. If you ship it in your theme files, it acts as the default that editors can override.
Block themes also support a taxonomy-genre.html template for genre archive pages, following the same naming pattern as classic themes.
Verifying in the Gutenberg Editor
Open your WordPress admin and go to Books > Add New. If the block editor loads (rather than the classic editor), show_in_rest is working. The taxonomy meta box appears in the sidebar under “Genres” (if you used the example above), and the featured image panel is visible because you included thumbnail in the supports array.
If the editor falls back to the classic TinyMCE interface, check two things:
- Confirm
'show_in_rest' => trueis in yourregister_post_type()arguments. - Confirm the Classic Editor plugin is not installed and active (it disables Gutenberg globally or per post type).
You can also force a post type to always use the classic editor by adding a filter:
add_filter( 'use_block_editor_for_post_type', function( $use, $post_type ) {
if ( 'book' === $post_type ) {
return false;
}
return $use;
}, 10, 2 );
But for most new builds you want the block editor, so leave show_in_rest on.
Verifying via the REST API
There are three ways to confirm the REST API endpoint exists and is returning data.
Browser check. Navigate to https://yoursite.com/wp-json/wp/v2/books. You should see a JSON array. If you get a 404, flush permalinks. If you get an empty array, the post type has no published posts yet.
wp-json index. Visit https://yoursite.com/wp-json/ and search the response for "books". The namespace section lists every registered route.
curl. A quick terminal check:
curl -s https://yoursite.com/wp-json/wp/v2/books | python3 -m json.tool | head -50
The response for each book includes id, slug, title, content, excerpt, featured_media, and the genre taxonomy terms under a key named after your rest_base.
If you added custom fields via register_meta() and want them in the REST response, pass 'show_in_rest' => true to the meta registration as well:
register_post_meta( 'book', 'isbn', array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
) );
The meta field then appears in the meta object in each REST response and becomes editable in the Gutenberg sidebar as a custom field.
Adding the Post Type to Nav Menus and Queries
By default, your custom post type will not appear in the WordPress nav menu builder, and its posts will not show up in the main blog loop. You have a few options.
Nav menus. Custom post type archives can be added to nav menus via Appearance > Menus (classic themes) or the Navigation block (block themes). In classic themes, you need to expand the “Custom Post Types” section in the menu builder, which only appears if your post type has 'show_in_nav_menus' => true (set automatically when public is true).
Main query. If you want books to appear in the main blog loop alongside regular posts, add them via pre_get_posts:
add_action( 'pre_get_posts', function( $query ) {
if ( $query->is_home() && $query->is_main_query() ) {
$query->set( 'post_type', array( 'post', 'book' ) );
}
} );
Be careful with this: it affects the front-page blog listing, not individual post pages or admin screens.
Custom WP_Query. For any custom loop, specify the post type explicitly:
$books = new WP_Query( array(
'post_type' => 'book',
'posts_per_page' => 10,
'tax_query' => array(
array(
'taxonomy' => 'genre',
'field' => 'slug',
'terms' => 'fiction',
),
),
) );
while ( $books->have_posts() ) {
$books->the_post();
the_title( '<h2>', '</h2>' );
}
wp_reset_postdata();
The tax_query filters by genre. Chaining multiple taxonomy conditions works the same way as with built-in taxonomies. The WordPress Transients API is useful here if this query runs on every page load and returns the same results for a period of time. You can see the full caching pattern in the guide to caching database queries with the WordPress Transients API.
Troubleshooting Common Problems
Archive page returns 404. Your custom post type is registered, but the rewrite rules are stale. Go to Settings > Permalinks and click Save Changes. Do this every time you change the rewrite argument or enable has_archive.
Gutenberg loads the classic editor. The show_in_rest argument is missing or set to false. The Classic Editor plugin is also a common culprit: disable it or configure it to allow the block editor for your post type.
REST API returns 404. Same cause as the archive 404: flush your rewrite rules. Also confirm the URL is correct: /wp-json/wp/v2/books (plural, matching your rest_base).
Taxonomy not appearing in block editor sidebar. The taxonomy registration is missing 'show_in_rest' => true. Without it, the block editor cannot fetch terms and the panel will not appear.
Posts not appearing on the archive page. Confirm has_archive is set to true in your registration. Also confirm the post status is publish, not draft.
Custom fields not in REST response. You need to register the meta with register_post_meta() and include 'show_in_rest' => true in that call. Values stored directly via update_post_meta() without registration are not exposed by default.
Keeping Your Registration Code Clean
Once you have more than two or three custom post types, keeping the registration code tidy matters. A few patterns help.
Split into separate files. Register each post type in its own file and include them from a central loader. This makes it easy to enable or disable a post type without touching unrelated code.
Store arguments in a config array. Instead of one massive register_post_type call, build the arguments from a configuration array at the top of the file. This makes it easier to scan what changed in a diff.
Add a deactivation flush. When your site-specific plugin is deactivated, flush the rewrite rules so the custom URLs stop returning 404s:
register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );
Use a prefix for your post type slug. The built-in post types use plain names like post and page. Third-party plugins also register names. Using a short prefix (like your project abbreviation) avoids collisions: mysite_book instead of book.
Capabilities and Permissions for Your Post Type
By default, 'capability_type' => 'post' maps your custom post type’s capabilities to the standard post capabilities. Any user who can edit posts can also edit books. This is usually fine for small teams, but larger sites often need finer control.
To create a fully independent capability set, set capability_type to your post type name and enable map_meta_cap:
'capability_type' => 'book',
'map_meta_cap' => true,
This generates capabilities like edit_book, delete_books, publish_books, and read_private_books. You then assign these capabilities to specific roles using a one-time script or a role editor plugin. Without map_meta_cap set to true, WordPress cannot map these custom capabilities to actual user permissions and administrators will lose access to the post type in the admin.
A simple one-time capability grant during plugin activation looks like this:
register_activation_hook( __FILE__, function() {
$admin = get_role( 'administrator' );
foreach ( [ 'edit_book', 'read_book', 'delete_book',
'edit_books', 'edit_others_books',
'publish_books', 'read_private_books' ] as $cap ) {
$admin->add_cap( $cap );
}
} );
Run this once on activation and the administrator role will have full access. Add similar blocks for editor or author roles as your workflow requires. Avoid calling get_role() and add_cap() on every page load since those operations write to the database.
What to Do Next
With your custom post type registered, the REST API enabled, and templates in place, you have a working content type. From here you can extend it in several directions.
Add custom meta boxes to capture structured data (author name, publication year, ISBN) and expose them in the block editor via register_post_meta().
Build a block that queries your custom post type and renders it in the editor using the Query Loop block or a custom block with useEntityRecords().
Connect to the REST API from a headless frontend to pull your content into a Next.js or Astro app. The endpoint you just enabled is the same one used by decoupled WordPress setups.
Add admin columns using the manage_{post_type}_posts_columns and manage_{post_type}_posts_custom_column hooks to display genre or ISBN directly in the Books list table without opening each post.
Custom post types are one of the most useful features in WordPress, and they do not require a plugin to get started. A few dozen lines of PHP, placed in the right hook, gives you a fully functional content type with its own admin UI, REST API, and template layer.
Beginner WordPress Tips Block Editor register post type WP development WP REST API
Last modified: May 8, 2026









