Create a Custom Post Type in WordPress (Code or Plugin)
To create a custom post type in WordPress, call register_post_type() on the init hook with a key, labels and settings, and set show_in_rest to true so it uses the block editor. Put the code in a small plugin rather than your theme’s functions.php, so the content survives a theme change. Then go to Settings > Permalinks and click Save Changes to stop its pages returning a 404. If you would rather not write code, a plugin such as Custom Post Type UI does the same job from the admin.
When a small business site needs one
WordPress comes with posts (dated, for news and articles) and pages (for fixed content). A custom post type is a third kind of content with its own admin menu, its own address and its own templates. It is worth adding when you have a group of items that share the same fields and should be listed together, for example:
- Projects for a builder, designer or agency: one entry per job, with photos, a suburb and a project type, listed at
/projects/. - Services, where each service has a page with the same layout and the services are listed on one overview page.
- Team members, with a photo, role and short bio, shown on an About page.
- Locations for a business with several sites, each with address and opening hours.
If you only have three or four of something and they never need listing, ordinary pages are simpler. The example below builds Projects; change the names to suit.
Option 1: Register the post type with code
Step 1: Create a small plugin
Create a folder called mysite-projects in wp-content/plugins/, and inside it a file called mysite-projects.php with this code:
<?php
/
* Plugin Name: Projects Content Type
* Description: Adds a Projects section with its own admin menu, archive and project types.
* Version: 1.0.0
* Requires at least: 6.4
* Requires PHP: 8.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function mysite_register_projects() {
register_post_type(
'project',
array(
'labels' => array(
'name' => ( 'Projects', 'mysite-projects' ),
'singular_name' => ( 'Project', 'mysite-projects' ),
'add_new_item' => ( 'Add New Project', 'mysite-projects' ),
'edit_item' => ( 'Edit Project', 'mysite-projects' ),
'new_item' => ( 'New Project', 'mysite-projects' ),
'view_item' => ( 'View Project', 'mysite-projects' ),
'view_items' => ( 'View Projects', 'mysite-projects' ),
'search_items' => ( 'Search Projects', 'mysite-projects' ),
'not_found' => ( 'No projects found.', 'mysite-projects' ),
'not_found_in_trash' => ( 'No projects found in Trash.', 'mysite-projects' ),
'all_items' => ( 'All Projects', 'mysite-projects' ),
),
'public' => true,
'has_archive' => true,
'rewrite' => array(
'slug' => 'projects',
'with_front' => false,
),
'show_in_rest' => true,
'menu_position' => 20,
'menu_icon' => 'dashicons-portfolio',
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ),
)
);
register_taxonomy(
'project_type',
'project',
array(
'labels' => array(
'name' => ( 'Project Types', 'mysite-projects' ),
'singular_name' => ( 'Project Type', 'mysite-projects' ),
),
'hierarchical' => true,
'show_in_rest' => true,
'show_admin_column' => true,
'rewrite' => array(
'slug' => 'project-type',
'with_front' => false,
),
)
);
}
add_action( 'init', 'mysite_register_projects' );
/*
* Register the rules once on activation so /projects/ works straight away,
* and remove them again on deactivation.
*/
function mysite_projects_activate() {
mysite_register_projects();
flush_rewrite_rules();
}
register_activation_hook( FILE, 'mysite_projects_activate' );
function mysite_projects_deactivate() {
unregister_post_type( 'project' );
unregister_taxonomy( 'project_type' );
flush_rewrite_rules();
}
register_deactivation_hook( FILE, 'mysite_projects_deactivate' );
Go to Plugins and activate Projects Content Type. A Projects menu appears in the admin, with Project Types under it.
What the settings do
'project'is the post type key. It is stored with every project in the database, so pick it once and do not change it. Keys must be 20 characters or fewer, lowercase, with no spaces. Avoid names WordPress reserves, such aspost,page,attachment,revision,action,author,orderandtheme; the register_post_type reference has the full list.publicmakes projects visible on the front end and in the admin.has_archivegives you a listing page at/projects/.rewritesets the address. With'slug' => 'projects', a project lives at/projects/project-name/.with_frontset to false stops any blog prefix in your permalink setting being added in front.show_in_restmakes the post type available in the REST API. It is also what lets projects use the block editor; without it, they open in the old classic editor.supportspicks the editing panels: title, content, excerpt, featured image and revisions here. Add'page-attributes'to get a manual Order field, or'custom-fields'for the custom fields panel.menu_icontakes the name of any Dashicon.
The register_taxonomy() call adds Project Types, which work like categories but only for projects (Residential, Commercial and so on). show_admin_column adds a Project Type column to the projects list in the admin.
Why a plugin and not functions.php
Most tutorials put this code in the theme’s functions.php. It works, but if the theme is ever changed, the projects disappear from the admin and the site. They are still in the database, but nothing registers them any more. A plugin keeps the post type registered whatever theme is active. If you want it impossible to deactivate by accident, put the file directly in wp-content/mu-plugins/ instead; must use plugins load automatically, but they do not run activation hooks, so save the permalinks yourself (step 2).
Step 2: Fix the 404 on project pages
WordPress keeps a stored list of address patterns (rewrite rules). A new post type’s addresses are not in that list until it is rebuilt, so /projects/ and each project return “Page not found”. The plugin above rebuilds the list once when it is activated. If you still see a 404, or you changed the slug later, go to Settings > Permalinks and click Save Changes without changing anything. That rebuilds the rules.
Do not call flush_rewrite_rules() on every page load to get around this. It is slow, and the WordPress reference says to run it only on activation, deactivation or when the rules change.
Option 2: Use a plugin instead of code
If you do not want to maintain code, two free plugins register post types and taxonomies from the admin:
- Custom Post Type UI covers the same settings as the code above, including the REST API and archive options, and can export the PHP for later.
- Advanced Custom Fields (ACF) registers post types and taxonomies as well as custom fields, so one plugin covers the whole job.
The trade off: the post type now depends on that plugin staying installed and active. Deactivate it and the content disappears from the admin, just as with a theme change.
Step 3: Show projects on the front end
A classic theme looks for these templates and falls back to single.php, archive.php and index.php if they are missing:
single-project.phpfor one projectarchive-project.phpfor the/projects/listingtaxonomy-project_type.phpfor each project type listing
A simple single-project.php, placed in your theme or child theme folder:
<?php get_header(); ?>
<main class="site-main">
<?php
while ( have_posts() ) :
the_post();
?>
<article <?php post_class( 'project' ); ?>>
<h1><?php the_title(); ?></h1>
<?php the_terms( get_the_ID(), 'project_type', '<p class="project-type">', ', ', '</p>' ); ?>
<?php the_post_thumbnail( 'large' ); ?>
<div class="entry-content">
<?php the_content(); ?>
</div>
</article>
<?php
endwhile;
?>
<p><a href="<?php echo esc_url( get_post_type_archive_link( 'project' ) ); ?>">All projects</a></p>
</main>
<?php get_footer(); ?>
And archive-project.php:
<?php get_header(); ?>
<main class="site-main">
<h1><?php post_type_archive_title(); ?></h1>
<?php if ( have_posts() ) : ?>
<div class="project-grid">
<?php
while ( have_posts() ) :
the_post();
?>
<article <?php post_class( 'project-card' ); ?>>
<a href="<?php the_permalink(); ?>">
<?php the_post_thumbnail( 'medium_large' ); ?>
<h2><?php the_title(); ?></h2>
</a>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
</div>
<?php the_posts_pagination(); ?>
<?php else : ?>
<p>No projects yet.</p>
<?php endif; ?>
</main>
<?php get_footer(); ?>
On a block theme, go to Appearance > Editor > Templates, add a new template and choose “Single item: Project” or “Archive: Projects”. Build the layout with blocks; the Query Loop block can list projects anywhere.
To show the three latest projects on another page of a classic theme, such as the homepage, use a separate query:
<?php
$recent_projects = new WP_Query(
array(
'post_type' => 'project',
'posts_per_page' => 3,
'no_found_rows' => true,
)
);
if ( $recent_projects->have_posts() ) :
echo '<ul class="recent-projects">';
while ( $recent_projects->have_posts() ) :
$recent_projects->the_post();
printf( '<li><a href="%s">%s</a></li>', esc_url( get_permalink() ), esc_html( get_the_title() ) );
endwhile;
echo '</ul>';
wp_reset_postdata();
endif;
Step 4: Add the fields your projects need
Title, content and featured image cover most of a project. For structured details such as suburb, completion date or client testimonial, add custom fields. The usual choice is ACF: create a field group, set its location rule to “Post Type is equal to Project”, and the fields appear on every project. Your templates then read them with get_post_meta() or ACF’s own functions, and should escape the output with esc_html().
Keep the number of fields small. Each one is something a person has to fill in for every project, and empty fields look worse on the page than no field at all.
Step 5: Check it before you rely on it
- Add two test projects with a featured image and a project type.
- Visit
/projects/, a single project and a project type page. If any returns a 404, save the permalinks again. - Add Projects to the menu: in Appearance > Menus, open Screen Options and tick Projects if it does not show, or link to
/projects/directly. - Check your SEO plugin includes projects in the sitemap and gives them a title and description. Rank Math and Yoast both list new public post types in their settings.
- Delete the test projects.
Common mistakes
- Changing the post type key later. Existing entries keep the old key and vanish from the admin. If you must rename, the entries need updating in the database.
- Using a slug that matches a page. A page called Projects at
/projects/and an archive at/projects/fight over the same address. Use the archive, or give one of them a different slug. - Leaving out
show_in_rest. The post type then opens in the classic editor and is missing from the REST API. - Registering it in the theme. Fine for a slider that only that theme uses, risky for real content.
If you would rather have it done properly the first time, with templates and fields that match your site, talk to us if you need a custom content type built. For building the theme side from scratch, see our WordPress theme development guide.
Frequently asked questions
Where do I put the code for a custom post type?
In a small plugin, or a file in wp-content/mu-plugins/. Putting it in the theme’s functions.php works, but the post type disappears from the admin if the theme is changed.
Why does my custom post type show a 404?
The rewrite rules have not been rebuilt since the post type was added or its slug changed. Go to Settings > Permalinks and click Save Changes.
Why does my custom post type use the classic editor?
It was registered without 'show_in_rest' => true. The block editor works through the REST API, so a post type that is not in the API falls back to the classic editor.
Is a custom post type the same as a custom field?
No. A post type is a kind of content, such as Projects. A custom field is an extra piece of data on one entry, such as a project’s suburb. Most custom post types use a few custom fields.
Do I need a plugin to create a custom post type?
No. The code in this guide is all it takes. Plugins such as Custom Post Type UI and ACF are an alternative if you would rather not maintain code.
Be the first to comment