How to Register Custom Post Types in a WordPress Theme and Display Them with Archive Templates

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)

  1. Register the post type on the init hook with register_post_type(), using a prefixed key such as md_project.
  2. 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.
  3. Set 'has_archive' => true and a 'rewrite' => array( 'slug' => 'projects' ) argument, then flush permalinks once.
  4. Create archive-md_project.php and single-md_project.php in the theme root (or templates/archive-md_project.html in a block theme).
  5. Register taxonomies before or alongside the post type and add taxonomy-md_project_type.php if you need a custom term listing.
wordpress code editor

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.

wordpress code editor

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, not project) 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 sees md_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();
} );
wordpress code editor

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}.phpsingle-md_project.phpsingle.phpsingular.phpindex.php
Project archive archive-md_project.phparchive.phpindex.php
Term archive taxonomy-md_project_type-{term}.phptaxonomy-md_project_type.phptaxonomy.phparchive.phpindex.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();
wordpress code editor

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' ) );
    }
} );
wordpress code editor

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.html
  • templates/single-md_project.html
  • templates/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

  1. Post type key prefixed, under 20 characters, not reserved
  2. Registration hooked to init, taxonomies at priority 0
  3. has_archive and rewrite['slug'] set, with_front disabled
  4. show_in_rest true and required supports declared
  5. Permalinks flushed once after deployment
  6. archive-{key}.php and single-{key}.php present in the theme
  7. Query controlled through pre_get_posts, never a duplicate loop
  8. All output escaped with esc_html(), esc_url(), wp_kses_post()
  9. Archive linked from the main navigation using get_post_type_archive_link()
  10. 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.

Leave a Comment