Registering custom post types in a WordPress theme looks simple: paste a register_post_type() call into functions.php, hit save, done. Then the archive returns a 404, the single view falls back to index.php, and the client switches themes and loses half their content.
This tutorial walks through the full workflow the way we do it on production sites at MarkupDude: where the registration code should really live, how rewrite slugs and taxonomies fit together, and how to build archive-{post_type}.php and single-{post_type}.php templates that WordPress actually picks up. Everything below is tested against WordPress 6.8+ and works in both classic and block themes.
Quick answer (TL;DR)
- Register the post type on the
inithook withregister_post_type(), using a prefixed key such asmd_project. - Put that code in a must-use plugin or a small site plugin, not in the theme, if the content must survive a theme change.
- Set
'has_archive' => trueand a'rewrite' => array( 'slug' => 'projects' )argument, then flush permalinks once. - Create
archive-md_project.phpandsingle-md_project.phpin the theme root (ortemplates/archive-md_project.htmlin a block theme). - Register taxonomies before or alongside the post type and add
taxonomy-md_project_type.phpif you need a custom term listing.

What a custom post type actually is
A custom post type is not a new database table. WordPress stores posts, pages, attachments, revisions, navigation menu items and every CPT in the same wp_posts table, separated by the post_type column. Registering a CPT tells WordPress:
- Which admin UI, labels and menu icon to display
- Which editor features to support (title, editor, thumbnail, excerpt, custom fields)
- Which URLs to generate and which rewrite rules to add
- Whether the type is exposed in the REST API and therefore usable in the block editor
Typical use cases: portfolio projects, staff members, testimonials, properties, events, case studies, documentation articles. Anything that has its own fields, its own archive and its own template.
Theme or plugin? Where the registration code belongs
This is the part most tutorials skip. Code in functions.php only runs while that theme is active. Deactivate the theme and the post type is gone from the admin, the URLs 404, and the rows stay orphaned in the database.
| Location | Survives theme switch? | Best for |
|---|---|---|
functions.php of the theme |
No | Prototypes, demos, one-off themes you control forever |
Theme inc/ file required from functions.php |
No | Cleaner organisation, same limitation |
Must-use plugin (wp-content/mu-plugins/) |
Yes, always loaded | Client sites, agency builds, anything long lived |
| Regular site plugin | Yes, while activated | Reusable content modules across several sites |
| CPT UI / ACF (generated) | Yes, while activated | Non-developers, quick setups, exportable to PHP later |
Our rule: the data definition (post type, taxonomies, fields) lives in a plugin, the presentation (archive and single templates) lives in the theme. That split keeps content portable and design replaceable. If you are building a distributed theme for WordPress.org, the theme review guidelines require this separation anyway. It is done convincingly by another team working this way.
Everything in this tutorial works identically whether you paste the registration snippet in functions.php or in a plugin file, so pick your poison and keep reading.

Step 1: Register the custom post type
Here is a complete, production-ready registration for a Projects post type. Add it to your plugin file or theme functions file.
<?php
/**
* Register the Project custom post type.
*/
function markupdude_register_project_cpt() {
$labels = array(
'name' => _x( 'Projects', 'Post type general name', 'markupdude' ),
'singular_name' => _x( 'Project', 'Post type singular name', 'markupdude' ),
'menu_name' => _x( 'Projects', 'Admin Menu text', 'markupdude' ),
'add_new_item' => __( 'Add New Project', 'markupdude' ),
'edit_item' => __( 'Edit Project', 'markupdude' ),
'view_item' => __( 'View Project', 'markupdude' ),
'all_items' => __( 'All Projects', 'markupdude' ),
'search_items' => __( 'Search Projects', 'markupdude' ),
'not_found' => __( 'No projects found.', 'markupdude' ),
'featured_image' => __( 'Project cover image', 'markupdude' ),
'archives' => __( 'Project archives', 'markupdude' ),
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_rest' => true,
'menu_position' => 20,
'menu_icon' => 'dashicons-portfolio',
'query_var' => true,
'capability_type' => 'post',
'map_meta_cap' => true,
'hierarchical' => false,
'has_archive' => 'projects',
'rewrite' => array(
'slug' => 'projects',
'with_front' => false,
'pages' => true,
),
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions', 'custom-fields', 'page-attributes' ),
'taxonomies' => array( 'md_project_type' ),
);
register_post_type( 'md_project', $args );
}
add_action( 'init', 'markupdude_register_project_cpt' );
The arguments that actually matter
| Argument | Why it matters |
|---|---|
public |
Master switch. Set to false for internal data (no front-end URL, no archive). |
has_archive |
Set true to get /md_project/, or pass a string to control the archive slug, here /projects/. Without it, archive-md_project.php is never used. |
rewrite['slug'] |
Controls the single post URL: /projects/my-project/. |
rewrite['with_front'] |
Set false so your permalink base (for example /blog/) is not prepended. |
show_in_rest |
Required for the block editor, the REST API and block theme queries. Leave it true unless you have a reason not to. |
supports |
Anything missing here will not appear in the editor. Forgetting thumbnail is the classic bug. |
hierarchical |
true makes it behave like pages (parent/child). Heavy on large datasets, use with care. |
Naming rules you cannot break
- Maximum 20 characters, lowercase, letters, numbers and underscores only.
- Always prefix the key (
md_project, notproject) to avoid collisions with plugins. - Never use reserved keys:
post,page,attachment,revision,nav_menu_item,action,author,order,theme,type. - The key and the slug are independent. Users see
/projects/, the database seesmd_project.
Step 2: Flush rewrite rules the right way
New rewrite rules are not active until they are regenerated. Do not call flush_rewrite_rules() on every page load, it is an expensive database write.
Easy method: go to Settings > Permalinks and click Save. That is enough on a site you administer. This reference is the one worth keeping handy.
Programmatic method for a plugin:
register_activation_hook( __FILE__, function () {
markupdude_register_project_cpt();
flush_rewrite_rules();
} );
register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );
If the CPT lives in the theme, hook the flush to the theme switch instead:
add_action( 'after_switch_theme', function () {
markupdude_register_project_cpt();
flush_rewrite_rules();
} );

Step 3: Add a custom taxonomy
Categories and tags belong to the post type by default. Give your CPT its own vocabulary so the two never mix. Register the taxonomy on init with an early priority so it exists when the post type is registered.
function markupdude_register_project_taxonomies() {
// Hierarchical, behaves like categories.
register_taxonomy(
'md_project_type',
array( 'md_project' ),
array(
'labels' => array(
'name' => __( 'Project Types', 'markupdude' ),
'singular_name' => __( 'Project Type', 'markupdude' ),
'menu_name' => __( 'Project Types', 'markupdude' ),
),
'public' => true,
'hierarchical' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'rewrite' => array(
'slug' => 'project-type',
'with_front' => false,
),
)
);
// Flat, behaves like tags.
register_taxonomy(
'md_project_tech',
array( 'md_project' ),
array(
'labels' => array(
'name' => __( 'Technologies', 'markupdude' ),
'singular_name' => __( 'Technology', 'markupdude' ),
),
'public' => true,
'hierarchical' => false,
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'tech', 'with_front' => false ),
)
);
}
add_action( 'init', 'markupdude_register_project_taxonomies', 0 );
Resulting URLs:
- Archive:
/projects/ - Single:
/projects/redesign-acme-corp/ - Term archive:
/project-type/web-design/ - Paged archive:
/projects/page/2/
Step 4: How the template hierarchy resolves CPT views
This is where most people get stuck. WordPress looks for template files in a strict order and uses the first one it finds in the theme (child theme first, then parent theme).
| View | Lookup order (classic theme) |
|---|---|
| Single project | single-md_project-{slug}.php → single-md_project.php → single.php → singular.php → index.php |
| Project archive | archive-md_project.php → archive.php → index.php |
| Term archive | taxonomy-md_project_type-{term}.php → taxonomy-md_project_type.php → taxonomy.php → archive.php → index.php |
| Custom page template | Any file in / or /templates/ with a Template Name and Template Post Type header |
Critical detail: the file name uses the post type key, not the rewrite slug. If your key is md_project but your slug is projects, the file must be named archive-md_project.php. Naming it archive-projects.php is the single most common reason a template silently does nothing.
Assigning a per-post custom template
Since WordPress 4.7 you can offer selectable templates for a CPT. Create templates/project-fullwidth.php with this header:
<?php
/**
* Template Name: Project Full Width
* Template Post Type: md_project
*/
The template then appears in the Template dropdown of the editor sidebar for projects only.
Step 5: Build archive-md_project.php
Create this file in the theme root. Keep the markup semantic and let the loop do the work.
<?php
/**
* Archive template for the Project custom post type.
*/
get_header();
?>
<main id="primary" class="site-main project-archive">
<header class="page-header">
<h1 class="page-title"><?php post_type_archive_title(); ?></h1>
<?php
$description = get_the_post_type_description();
if ( $description ) {
echo '<div class="archive-description">' . wp_kses_post( $description ) . '</div>';
}
?>
<?php
// Simple term filter for the archive.
$terms = get_terms( array(
'taxonomy' => 'md_project_type',
'hide_empty' => true,
) );
if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) : ?>
<nav class="project-filters" aria-label="<?php esc_attr_e( 'Filter projects', 'markupdude' ); ?>">
<ul>
<li><a href="<?php echo esc_url( get_post_type_archive_link( 'md_project' ) ); ?>"><?php esc_html_e( 'All', 'markupdude' ); ?></a></li>
<?php foreach ( $terms as $term ) : ?>
<li>
<a href="<?php echo esc_url( get_term_link( $term ) ); ?>"><?php echo esc_html( $term->name ); ?></a>
</li>
<?php endforeach; ?>
</ul>
</nav>
<?php endif; ?>
</header>
<?php if ( have_posts() ) : ?>
<div class="project-grid">
<?php
while ( have_posts() ) :
the_post();
?>
<article id="post-<?php the_ID(); ?>" <?php post_class( 'project-card' ); ?>>
<?php if ( has_post_thumbnail() ) : ?>
<a class="project-card__thumb" href="<?php the_permalink(); ?>">
<?php the_post_thumbnail( 'medium_large', array( 'loading' => 'lazy' ) ); ?>
</a>
<?php endif; ?>
<h2 class="project-card__title">
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
</h2>
<?php echo get_the_term_list( get_the_ID(), 'md_project_type', '<p class="project-card__terms">', ', ', '</p>' ); ?>
<div class="project-card__excerpt"><?php the_excerpt(); ?></div>
</article>
<?php endwhile; ?>
</div>
<?php
the_posts_pagination( array(
'mid_size' => 2,
'prev_text' => __( 'Previous', 'markupdude' ),
'next_text' => __( 'Next', 'markupdude' ),
) );
?>
<?php else : ?>
<p><?php esc_html_e( 'No projects published yet.', 'markupdude' ); ?></p>
<?php endif; ?>
</main>
<?php
get_footer();

Step 6: Build single-md_project.php
<?php
/**
* Single template for the Project custom post type.
*/
get_header();
while ( have_posts() ) :
the_post();
?>
<main id="primary" class="site-main">
<article id="post-<?php the_ID(); ?>" <?php post_class( 'project-single' ); ?>>
<header class="entry-header">
<h1 class="entry-title"><?php the_title(); ?></h1>
<?php
$client = get_post_meta( get_the_ID(), 'md_project_client', true );
$year = get_post_meta( get_the_ID(), 'md_project_year', true );
if ( $client || $year ) :
?>
<ul class="project-meta">
<?php if ( $client ) : ?>
<li><strong><?php esc_html_e( 'Client:', 'markupdude' ); ?></strong> <?php echo esc_html( $client ); ?></li>
<?php endif; ?>
<?php if ( $year ) : ?>
<li><strong><?php esc_html_e( 'Year:', 'markupdude' ); ?></strong> <?php echo esc_html( $year ); ?></li>
<?php endif; ?>
</ul>
<?php endif; ?>
</header>
<?php if ( has_post_thumbnail() ) : ?>
<figure class="project-hero"><?php the_post_thumbnail( 'full' ); ?></figure>
<?php endif; ?>
<div class="entry-content">
<?php
the_content();
wp_link_pages( array(
'before' => '<div class="page-links">',
'after' => '</div>',
) );
?>
</div>
<footer class="entry-footer">
<?php echo get_the_term_list( get_the_ID(), 'md_project_tech', '<p class="project-tech">', ', ', '</p>' ); ?>
<p><a href="<?php echo esc_url( get_post_type_archive_link( 'md_project' ) ); ?>"><?php esc_html_e( 'Back to all projects', 'markupdude' ); ?></a></p>
</footer>
</article>
<?php
the_post_navigation( array(
'prev_text' => '<span>' . esc_html__( 'Previous project', 'markupdude' ) . '</span> %title',
'next_text' => '<span>' . esc_html__( 'Next project', 'markupdude' ) . '</span> %title',
) );
?>
</main>
<?php
endwhile;
get_footer();
Taxonomy template
If archive-md_project.php already renders your grid nicely, you can reuse it for term archives instead of duplicating code. Create taxonomy-md_project_type.php containing just:
<?php
// Reuse the project archive layout for project type terms.
require get_template_directory() . '/archive-md_project.php';
Adapt post_type_archive_title() into a conditional if you want the term name as the heading:
<?php
if ( is_tax() ) {
single_term_title();
} else {
post_type_archive_title();
}
Step 7: Control the archive query with pre_get_posts
Never run a second WP_Query inside an archive template just to change the number of posts or the sort order. It breaks pagination and doubles the database load. Filter the main query instead.
function markupdude_project_archive_query( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
if ( is_post_type_archive( 'md_project' ) || is_tax( array( 'md_project_type', 'md_project_tech' ) ) ) {
$query->set( 'posts_per_page', 12 );
$query->set( 'orderby', 'menu_order date' );
$query->set( 'order', 'ASC' );
}
}
add_action( 'pre_get_posts', 'markupdude_project_archive_query' );
Another common need is including a CPT in the main blog feed or the search results:
add_action( 'pre_get_posts', function ( $query ) {
if ( ! is_admin() && $query->is_main_query() && $query->is_search() ) {
$query->set( 'post_type', array( 'post', 'page', 'md_project' ) );
}
} );

Doing the same in a block theme (FSE)
Block themes use HTML template files instead of PHP, but the hierarchy rules are identical. Place the files in the templates folder:
templates/archive-md_project.htmltemplates/single-md_project.htmltemplates/taxonomy-md_project_type.html
A minimal archive template:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:query-title {"type":"archive","level":1} /-->
<!-- wp:query {"query":{"perPage":12,"postType":"md_project","inherit":true},"layout":{"type":"default"}} -->
<div class="wp-block-query">
<!-- wp:post-template {"layout":{"type":"grid","columnCount":3}} -->
<!-- wp:post-featured-image {"isLink":true} /-->
<!-- wp:post-title {"isLink":true,"level":2} /-->
<!-- wp:post-terms {"term":"md_project_type"} /-->
<!-- wp:post-excerpt /-->
<!-- /wp:post-template -->
<!-- wp:query-pagination -->
<!-- wp:query-pagination-previous /-->
<!-- wp:query-pagination-numbers /-->
<!-- wp:query-pagination-next /-->
<!-- /wp:query-pagination -->
</div>
<!-- /wp:query -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
Two requirements for block themes: show_in_rest must be true, and the taxonomy must also be REST enabled if you want the Post Terms block to render it. Add a human readable name for the template in theme.json under customTemplates if you want it selectable in the site editor.
Troubleshooting: why your CPT template is not loading
| Symptom | Cause and fix |
|---|---|
| Single post returns 404 | Rewrite rules not flushed. Save the Permalinks screen once. |
| Archive URL returns 404 | has_archive is false, or the archive slug collides with an existing page slug. |
| Template file ignored | File named with the rewrite slug instead of the post type key, or placed in a subfolder instead of the theme root. |
| CPT missing from admin menu | Registered after init, or show_ui is false, or capabilities do not map to the current role. |
| Block editor shows the classic editor | show_in_rest is false or editor is missing from supports. |
| Taxonomy terms not saving | Taxonomy registered after the post type without being attached, or missing from the taxonomies argument. |
| Pagination shows page 2 as 404 | A custom WP_Query is used without the paged parameter. Use pre_get_posts instead. |
| Content disappears after theme switch | CPT registered in functions.php. Move the registration to a must-use plugin, the posts are still in the database. |
Deployment checklist
- Post type key prefixed, under 20 characters, not reserved
- Registration hooked to
init, taxonomies at priority 0 has_archiveandrewrite['slug']set,with_frontdisabledshow_in_resttrue and requiredsupportsdeclared- Permalinks flushed once after deployment
archive-{key}.phpandsingle-{key}.phppresent in the theme- Query controlled through
pre_get_posts, never a duplicate loop - All output escaped with
esc_html(),esc_url(),wp_kses_post() - Archive linked from the main navigation using
get_post_type_archive_link() - Registration code stored in a plugin if the site may ever change theme
FAQ
Can I create a custom post type in WordPress without a plugin?
Yes. Add register_post_type() to your theme’s functions.php on the init hook and flush permalinks. It works perfectly, but the post type stops existing on the front end the moment the theme is deactivated, so use a must-use plugin for anything a client will own long term. Someone has put together a good summary of it.
What is the difference between the post type key and the rewrite slug?
The key (md_project) is the internal identifier stored in the database and used for template file names. The slug (projects) is what appears in URLs. They can be different, and on well-built sites they usually are.
Why is archive-{post_type}.php not being used?
Three usual suspects: the post type was registered with has_archive set to false, the rewrite rules were not flushed, or the file name uses the slug rather than the post type key. Check get_post_type_archive_link( 'your_key' ) to confirm WordPress even generated an archive URL. wordpress.org walks through the specifics.
Do custom post types need their own taxonomies?
Not strictly. You can attach the built-in category and post_tag taxonomies by adding them to the taxonomies argument. However, mixing blog categories with project categories usually creates confusing archives, so a dedicated taxonomy is the cleaner choice.
Do custom post types affect SEO?
Only in a good way when configured properly. CPTs produce clean, topical URL structures and dedicated archives that search engines can crawl. Just make sure that archives you do not want indexed (thin taxonomy archives, for example) are handled through your SEO plugin’s indexing rules.
Should I use Custom Post Type UI or ACF instead of writing code?
They are excellent for speed and for non-developers, and both can export the equivalent PHP. For a theme you are developing yourself, hand written registration gives you version control, code review and zero extra dependency at runtime. Whichever route you choose, the templates in this guide work exactly the same way.
How many custom post types is too many?
There is no hard limit, but each public CPT adds rewrite rules and admin queries. Beyond roughly ten to fifteen public post types, consider whether some of those content sets are really just taxonomy terms or post meta on an existing type.
Need a custom theme built the right way, with portable content structures and templates that hold up over time? The MarkupDude team builds pixel accurate WordPress themes from design files every day. Get in touch and tell us what you are building.
