Custom Post Type Archive Templates in WordPress: The Template Hierarchy, has_archive, and Patterns That Actually Work
If you’ve registered a custom post type and the archive URL returns a 404 β or it works but uses your default index.php template when you wanted something custom β you’ve hit the WordPress template hierarchy at exactly the place where most people get stuck. The fix is usually a one-line change to your post type registration plus a properly-named template file, but the why behind it requires understanding how WordPress decides which template to use for any given request.
This guide covers the template hierarchy as it applies to custom post type archives, the register_post_type() arguments that determine whether archives exist at all, and the patterns for building archive templates that handle the realistic cases (taxonomy archives, pagination, custom queries) instead of just the textbook example.
The WordPress template hierarchy for custom post type archives
WordPress’s template hierarchy is a chain of file names it checks in order when rendering a page. For a custom post type archive (the URL like /books/ or /products/), WordPress checks for these files in your active theme, in this order:
archive-{post_type}.phpβ for example,archive-book.phpif your post type slug isbook. This is the primary custom archive template.archive.phpβ used for any archive (categories, tags, custom post types, dates) when no more specific template exists.index.phpβ the fallback that WordPress always uses if nothing more specific matches.
For taxonomy archives associated with custom post types (e.g., /genre/comedy/ for a genre taxonomy attached to your book post type), the hierarchy is different:
taxonomy-{taxonomy}-{term}.phpβ most specific:taxonomy-genre-comedy.phpfor the “comedy” term in thegenretaxonomy.taxonomy-{taxonomy}.phpβtaxonomy-genre.phpfor any term in thegenretaxonomy.taxonomy.phpβ for any taxonomy.archive.phpβ generic archive fallback.index.phpβ final fallback.
The single biggest “why doesn’t my template work” cause is naming. WordPress is looking for archive-book.php, not book-archive.php or archive_book.php. The hyphen-separated pattern with the post type slug is required.
Do you actually need a custom archive template?
Before building a custom archive template, check whether the default archive.php is good enough. For many sites, it is. Default WordPress themes ship with archive.php templates that handle custom post types reasonably out of the box β they just use the post type’s default loop styling without anything custom.
You need a custom archive template when:
- The archive needs a different layout from your default
archive.php(different sidebar, no sidebar, different post listing styling) - You need post-type-specific custom queries (filtering by meta values, custom ordering, etc.)
- You want a header banner, hero section, or other elements specific to this post type’s archive
- The archive needs unique CSS classes or template structure for styling purposes
You don’t need a custom archive template when:
- Your default
archive.phpalready renders the custom post type the way you want - The archive just needs minor adjustments that can be handled via CSS based on body classes
- You haven’t enabled
has_archiveinregister_post_type()and don’t intend to (in which case the archive URL doesn’t exist at all)
A surprising portion of “I need to build a custom archive template” projects end with “I just need to override archive.php slightly with body class targeting” β much less work for the same visual result.
Enabling archives in register_post_type()
Before any template can render, the post type itself needs to support archives. This is controlled by the has_archive argument in register_post_type().
The minimum registration for a post type that supports archives:
add_action( 'init', function() {
register_post_type( 'book', [
'labels' => [
'name' => 'Books',
'singular_name' => 'Book',
],
'public' => true,
'has_archive' => true,
'rewrite' => [ 'slug' => 'books' ],
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
'show_in_rest' => true,
] );
} );
The key arguments:
has_archiveβ whentrue, the archive URL/books/becomes available. Can also be a string to specify a different URL slug (e.g.,'has_archive' => 'library'makes the archive at/library/).rewriteβ controls the URL slug for individual posts (/books/title-here/). Defaults to the post type slug.publicβ must betruefor the archive to be publicly visible.
After adding or changing these arguments, you need to flush rewrite rules. The cleanest way: visit Settings β Permalinks and click Save (no changes needed). For production code, flush rewrite rules on plugin activation:
register_activation_hook( __FILE__, function() {
// Your post type registration function
myplugin_register_post_types();
flush_rewrite_rules();
} );
Never call flush_rewrite_rules() on every page load β it’s expensive. Only on activation or when something specifically changes.
Building archive-{post_type}.php
The most common custom archive template structure:
<?php
/**
* Archive template for the Book post type.
*/
get_header();
?>
<main id="primary" class="site-main book-archive">
<header class="page-header">
<h1 class="page-title">All Books</h1>
<?php the_archive_description( '<div class="archive-description">', '</div>' ); ?>
</header>
<?php if ( have_posts() ) : ?>
<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(); ?>" class="book-card-link">
<?php if ( has_post_thumbnail() ) : ?>
<?php the_post_thumbnail( 'medium' ); ?>
<?php endif; ?>
<h2 class="book-title"><?php the_title(); ?></h2>
<?php the_excerpt(); ?>
</a>
</article>
<?php endwhile; ?>
</div>
<?php the_posts_pagination(); ?>
<?php else : ?>
<p>No books found.</p>
<?php endif; ?>
</main>
<?php
get_footer();
This template handles the standard cases β title, description, post loop with thumbnail, excerpt, and pagination. Build from this and customize for your specific design needs.
Custom queries within archive templates
The default archive query handles most cases, but sometimes you need to modify it β different sort order, custom filtering, joining with custom fields. There are two approaches: modify the main query with pre_get_posts, or run a secondary WP_Query inside the template.
Modifying the main query (preferred)
Use pre_get_posts to adjust the query before it runs. This keeps pagination working correctly and respects WordPress’s main query lifecycle:
add_action( 'pre_get_posts', function( $query ) {
if ( $query->is_main_query() && ! is_admin() && is_post_type_archive( 'book' ) ) {
$query->set( 'posts_per_page', 12 );
$query->set( 'orderby', 'meta_value_num' );
$query->set( 'meta_key', 'publication_year' );
$query->set( 'order', 'DESC' );
}
} );
Place this in your theme’s functions.php or in a plugin. The is_post_type_archive( 'book' ) check ensures the modification only applies to your specific archive.
Secondary WP_Query (when you need multiple queries)
If your archive page needs to show both the default archive AND a separate query (e.g., “featured books” sidebar), use a secondary WP_Query:
$featured = new WP_Query( [
'post_type' => 'book',
'posts_per_page' => 3,
'meta_query' => [
[
'key' => 'featured',
'value' => '1',
],
],
] );
if ( $featured->have_posts() ) :
while ( $featured->have_posts() ) : $featured->the_post();
// Render featured book
endwhile;
wp_reset_postdata();
endif;
The wp_reset_postdata() call is critical β it restores the main query’s post data so the rest of your template behaves correctly.
Block theme archive templates
If your theme is a block theme (Twenty Twenty-Four, Twenty Twenty-Five, or any custom theme using theme.json + block templates), archive templates live in the templates/ directory and use HTML files with block markup instead of PHP.
For a book post type, the file is templates/archive-book.html. The template hierarchy resolution is the same as classic themes β block themes check archive-book.html first, then archive.html, then index.html.
A minimal block theme archive-book.html:
<!-- 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:term-description /-->
<!-- wp:query {"queryId":1,"query":{"perPage":12,"postType":"book","orderBy":"date","order":"desc","inherit":true}} -->
<div class="wp-block-query">
<!-- wp:post-template {"layout":{"type":"grid","columnCount":3}} -->
<!-- wp:post-featured-image {"isLink":true,"width":"100%","aspectRatio":"3/4"} /-->
<!-- wp:post-title {"isLink":true,"level":2} /-->
<!-- wp:post-excerpt {"moreText":"Read more β"} /-->
<!-- /wp:post-template -->
<!-- wp:query-pagination -->
<!-- wp:query-pagination-previous /-->
<!-- wp:query-pagination-numbers /-->
<!-- wp:query-pagination-next /-->
<!-- /wp:query-pagination -->
<!-- wp:query-no-results -->
<!-- wp:paragraph -->
<p>No books found.</p>
<!-- /wp:paragraph -->
<!-- /wp:query-no-results -->
</div>
<!-- /wp:query -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
Block theme archives use the Query Loop block with inherit: true to pick up the main query for the archive (so pagination and pre_get_posts modifications still work). The postType parameter on the Query block is what scopes it to the specific CPT.
You can also create or edit block templates directly in the Site Editor (Appearance β Editor β Templates) β the resulting templates save as HTML files in the active theme’s templates/ directory.
Adding schema.org markup to archives
Search engines use schema.org structured data to understand what an archive page contains. For a CPT archive, the appropriate schema is typically CollectionPage or ItemList. WordPress SEO plugins (Rank Math, Yoast) generate basic schema automatically, but you may need to customize it for non-standard post types.
For Rank Math users, register your custom post type in the Schema settings and it’ll handle CollectionPage markup automatically. For custom schema implementation:
add_action( 'wp_head', function() {
if ( ! is_post_type_archive( 'book' ) ) {
return;
}
$items = [];
$query = new WP_Query( [
'post_type' => 'book',
'posts_per_page' => 20,
'no_found_rows' => true,
] );
if ( $query->have_posts() ) {
$position = 1;
while ( $query->have_posts() ) {
$query->the_post();
$items[] = [
'@type' => 'ListItem',
'position' => $position,
'url' => get_permalink(),
'name' => get_the_title(),
];
$position++;
}
wp_reset_postdata();
}
$schema = [
'@context' => 'https://schema.org',
'@type' => 'CollectionPage',
'name' => 'All Books',
'description' => 'Browse our complete book catalog.',
'url' => get_post_type_archive_link( 'book' ),
'mainEntity' => [
'@type' => 'ItemList',
'itemListElement' => $items,
],
];
echo '<script type="application/ld+json">' . wp_json_encode( $schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . '</script>';
} );
This produces structured data Google can use to understand the archive’s contents. Test the output with Google’s Rich Results Test before relying on it.
Reusing post cards across templates with get_template_part()
A common pattern: the same “post card” appears on the archive, on search results, on related-posts widgets. Rather than duplicating the markup, extract it to a template part.
Create template-parts/book-card.php:
<article id="post-<?php the_ID(); ?>" <?php post_class( 'book-card' ); ?>>
<a href="<?php the_permalink(); ?>" class="book-card-link">
<?php if ( has_post_thumbnail() ) : ?>
<?php the_post_thumbnail( 'medium', [ 'class' => 'book-card-thumb' ] ); ?>
<?php endif; ?>
<h2 class="book-title"><?php the_title(); ?></h2>
<?php
$author = get_post_meta( get_the_ID(), 'book_author', true );
if ( $author ) {
echo '<p class="book-author">by ' . esc_html( $author ) . '</p>';
}
?>
<div class="book-excerpt"><?php the_excerpt(); ?></div>
</a>
</article>
Then in archive-book.php:
while ( have_posts() ) : the_post();
get_template_part( 'template-parts/book-card' );
endwhile;
When the card design changes, you update one file and every place using it inherits the change. Combine with get_template_part( 'template-parts/book-card', 'featured' ) to load a book-card-featured.php variant when needed.
Faceted filtering and sort UI
For archives that need user-facing filters (price ranges, categories, tags, custom fields), the realistic options:
- FacetWP (paid) β purpose-built faceted search for WordPress, integrates with WP_Query and pre_get_posts
- SearchWP (paid) β search-focused with filtering features
- Manual filter UI β build forms that submit URL parameters, then read them in
pre_get_poststo filter the query
The manual approach for a basic filter:
// In the archive template, render a filter form
?>
<form method="get" class="book-filters">
<select name="genre">
<option value="">All genres</option>
<?php
$genres = get_terms( [ 'taxonomy' => 'genre', 'hide_empty' => true ] );
$current = sanitize_key( $_GET['genre'] ?? '' );
foreach ( $genres as $genre ) {
$selected = selected( $current, $genre->slug, false );
echo '<option value="' . esc_attr( $genre->slug ) . '"' . $selected . '>' . esc_html( $genre->name ) . '</option>';
}
?>
</select>
<button type="submit">Filter</button>
</form>
<?php
// In functions.php, apply the filter to the main query
add_action( 'pre_get_posts', function( $query ) {
if ( ! $query->is_main_query() || is_admin() || ! is_post_type_archive( 'book' ) ) {
return;
}
$genre = sanitize_key( $_GET['genre'] ?? '' );
if ( $genre ) {
$query->set( 'tax_query', [
[
'taxonomy' => 'genre',
'field' => 'slug',
'terms' => [ $genre ],
],
] );
}
} );
For multi-facet filters (genre + price range + author), build out the form fields and the pre_get_posts logic for each. At some complexity threshold, switching to FacetWP saves significant maintenance.
Pinning featured posts at the top of an archive
Sticky posts at the top of an archive is a common pattern. Two approaches:
Approach 1 β Use WordPress’s built-in sticky posts. For the default post type, sticky posts work natively. For custom post types, you need to add the support and modify the query:
// Add to post type registration
'supports' => [ 'title', 'editor', 'thumbnail', 'excerpt', 'sticky' ],
// Pin sticky posts at top of archive
add_action( 'pre_get_posts', function( $query ) {
if ( ! $query->is_main_query() || is_admin() || ! is_post_type_archive( 'book' ) ) {
return;
}
$sticky = get_option( 'sticky_posts' );
if ( ! empty( $sticky ) ) {
$query->set( 'post__in', $sticky );
$query->set( 'ignore_sticky_posts', false );
}
} );
Approach 2 β Use a “featured” meta field. Run two queries: featured items first, then everything else excluding featured:
// Featured query at top
$featured = new WP_Query( [
'post_type' => 'book',
'posts_per_page' => 3,
'meta_key' => 'featured',
'meta_value' => '1',
] );
// Main loop excluding featured (use pre_get_posts to apply)
add_action( 'pre_get_posts', function( $query ) {
if ( $query->is_main_query() && ! is_admin() && is_post_type_archive( 'book' ) ) {
$query->set( 'meta_query', [
[
'key' => 'featured',
'compare' => 'NOT EXISTS',
],
] );
}
} );
The custom meta approach is more flexible (different “featured” rules per archive, time-bound features) but adds complexity.
Search within a specific post type
If you want a search box that only searches within one CPT (rather than all post types), pass the post type as a URL parameter:
// In your search form
?>
<form action="<?php echo esc_url( home_url( '/' ) ); ?>" method="get">
<input type="search" name="s" placeholder="Search books..." />
<input type="hidden" name="post_type" value="book" />
<button type="submit">Search</button>
</form>
<?php
WordPress respects the post_type query var on search, scoping the results to your CPT. The search results page will use the standard search template (search.php or index.php).
To customize the search results template for that specific CPT, hook into template_include:
add_filter( 'template_include', function( $template ) {
if ( is_search() && get_query_var( 'post_type' ) === 'book' ) {
$custom = locate_template( 'search-book.php' );
if ( $custom ) {
return $custom;
}
}
return $template;
} );
Then create search-book.php in your theme with book-specific search result rendering.
Taxonomy archive templates
Custom post types often have associated custom taxonomies, and the taxonomy archives have their own template hierarchy. For a genre taxonomy attached to the book post type, term-specific URLs like /genre/comedy/ need a separate template.
File names in priority order:
taxonomy-genre-comedy.phpβ only for the “comedy” termtaxonomy-genre.phpβ for any term in the genre taxonomytaxonomy.phpβ for any taxonomy
The taxonomy template structure mirrors the archive template:
<?php
/**
* Template for genre taxonomy archives.
*/
get_header();
$term = get_queried_object();
?>
<main id="primary" class="site-main genre-archive">
<header class="page-header">
<h1 class="page-title"><?php echo esc_html( $term->name ); ?> Books</h1>
<?php if ( $term->description ) : ?>
<div class="taxonomy-description">
<?php echo wp_kses_post( $term->description ); ?>
</div>
<?php endif; ?>
</header>
<?php if ( have_posts() ) : ?>
<div class="book-grid">
<?php while ( have_posts() ) : the_post(); ?>
<?php get_template_part( 'template-parts/book-card' ); ?>
<?php endwhile; ?>
</div>
<?php the_posts_pagination(); ?>
<?php endif; ?>
</main>
<?php get_footer();
Note get_queried_object() to access the current term β useful for showing the term name, description, and any custom fields attached to the term.
Common problems and solutions
The archive URL returns 404
This is the most common problem after enabling has_archive. Almost always solved by flushing rewrite rules.
Fix: Visit Settings β Permalinks and click Save Changes without modifying anything. WordPress regenerates the rewrite rules and your archive URL should work.
For plugins: Call flush_rewrite_rules() in your activation hook. Don’t call it on every page load.
The archive uses index.php instead of my custom template
The file naming is wrong, the file is in the wrong location, or the active theme is overriding the template hierarchy.
Diagnosis: Verify the file is exactly archive-{your-post-type-slug}.php (hyphens, lowercase, exact post type slug match). Verify it’s in your active theme’s root directory, not in a subdirectory. Verify your post type slug matches exactly.
// Confirm the post type slug
add_action( 'init', function() {
error_log( 'Post type slug: ' . get_post_type_object( 'book' )->name );
}, 99 );
For block themes (Twenty Twenty-Four and similar), templates live in the templates/ directory and use HTML files (archive-book.html), not PHP. The template hierarchy still applies, but the file extension is different.
Pagination doesn’t work on the archive
Usually caused by modifying the query without preserving pagination, or by hardcoding posts_per_page somewhere.
Fix: Ensure your pre_get_posts modification preserves the paged parameter. Use the_posts_pagination() rather than previous_posts_link() / next_posts_link() for cleaner pagination markup. Verify your hosting doesn’t have a redirect rule that strips pagination parameters.
Custom queries break the loop or pagination
The most common cause is forgetting wp_reset_postdata() after a secondary WP_Query, leaving WordPress confused about which query owns the current post data.
Fix: Always call wp_reset_postdata() after running a secondary WP_Query. Always call wp_reset_query() if you’ve used query_posts() (better still, avoid query_posts() entirely β use pre_get_posts instead).
The archive shows the wrong number of posts
Usually caused by a posts_per_page setting somewhere (Settings β Reading, or hardcoded in functions.php) that doesn’t match what you want for this specific archive.
Fix: Use pre_get_posts to set posts_per_page specifically for your post type archive:
add_action( 'pre_get_posts', function( $query ) {
if ( $query->is_main_query() && ! is_admin() && is_post_type_archive( 'book' ) ) {
$query->set( 'posts_per_page', 12 );
}
} );
This overrides the site-wide setting only for the specific archive.
archive-{post_type}.php ignored despite correct naming
In rare cases, a parent theme’s archive template takes precedence in unexpected ways, or a plugin is overriding template selection.
Diagnosis: Use the template_include filter to see what WordPress is actually choosing:
add_filter( 'template_include', function( $template ) {
error_log( 'Template chosen: ' . $template );
return $template;
} );
Reload the archive page and check the error log. If the path isn’t your archive-{post_type}.php, you’ll see which file WordPress chose instead.
Performance considerations for archive templates
Archive pages can show many posts at once, and each post may require multiple database queries to render fully. Watch for these performance pitfalls.
Avoid N+1 query patterns in the loop. Calling get_post_meta() for each post in a loop runs a separate query per post. For 20 posts, that’s 20+ extra queries. Use update_post_meta_cache() or pre-fetch all meta with a single query before the loop.
Limit posts_per_page to a reasonable number. Showing 100 posts per archive page increases query cost and slows the page. Most archives work well at 10β20 posts per page with pagination.
Use appropriate image sizes. Calling the_post_thumbnail( 'large' ) when the thumbnail will be displayed at 300×300 wastes bandwidth and slows rendering. Use the smallest image size that meets your design.
Cache archive queries when appropriate. For archives that don’t change frequently, full-page caching (via a caching plugin or hosting-level cache) is effective. For more dynamic archives, object caching helps reduce repeated query costs.
Frequently asked questions
What’s the difference between archive-{post_type}.php and single-{post_type}.php?
archive-{post_type}.php renders the listing page showing multiple posts of that type (e.g., /books/). single-{post_type}.php renders an individual post (e.g., /books/great-expectations/). They serve different URLs and use different templates.
Why doesn’t is_post_type_archive() work in my template?
is_post_type_archive() only returns true on the actual post type archive page. If you’re on a category archive that happens to include posts of that type, or a search results page, is_post_type_archive() will return false. Use get_query_var( 'post_type' ) for broader queries.
Can I have an archive without has_archive => true?
You can build something archive-like with a page template or a shortcode that queries the post type. But you can’t have the WordPress-standard archive URL (/books/) without has_archive => true in the post type registration. For most cases, the standard archive is what you want.
How do I customize the archive title?
Use the get_the_archive_title filter:
add_filter( 'get_the_archive_title', function( $title ) {
if ( is_post_type_archive( 'book' ) ) {
return 'Our Book Collection';
}
return $title;
} );
This changes the title returned by the_archive_title() without modifying your template files.
How do I exclude certain posts from the archive?
Use pre_get_posts to modify the query:
add_action( 'pre_get_posts', function( $query ) {
if ( $query->is_main_query() && ! is_admin() && is_post_type_archive( 'book' ) ) {
$query->set( 'meta_query', [
[
'key' => 'hidden_from_archive',
'compare' => 'NOT EXISTS',
],
] );
}
} );
This excludes posts with a hidden_from_archive meta key set.
What about block themes β do archive templates still work?
Yes. Block themes use templates/archive-{post-type}.html instead of archive-{post-type}.php. The template hierarchy is the same, but the file format is HTML with block markup. You can edit these templates through the Site Editor or directly in the theme files.
How do I show featured posts first on the archive?
Two patterns work: WordPress’s built-in sticky posts (add 'sticky' to your post type’s supports and modify the query in pre_get_posts), or a custom “featured” meta field (run a featured query first, then the main loop excluding featured items). Custom meta is more flexible if you want time-bound features or different rules per archive; built-in sticky is simpler if “featured forever” is good enough. See the “Pinning featured posts” section above for code.
Can I paginate custom WP_Query results?
Yes, but you need to pass paged to the secondary query manually:
$paged = get_query_var( 'paged' ) ? get_query_var( 'paged' ) : 1;
$featured = new WP_Query( [
'post_type' => 'book',
'posts_per_page' => 12,
'paged' => $paged,
] );
Then use paginate_links() with the secondary query’s max_num_pages to render pagination. The main loop and the secondary loop pagination are separate β be aware of which paged parameter belongs to which.
How does object caching affect archive queries?
Persistent object caching (Redis, Memcached) caches the results of database queries for the lifetime of the cache. For archives that don’t change frequently, this dramatically reduces query load β the same archive page can serve thousands of visitors with one database hit. For frequently-updated archives, ensure the cache invalidates correctly when posts are added/removed; most caching plugins handle this automatically, but custom cache strategies need explicit invalidation. Check wp_cache_get() and wp_cache_set() patterns if you’re implementing custom caching for specific archive queries.
How do I add a custom field to the archive title or content?
If the custom field is attached to the post type itself (rare) or to the term (for taxonomy archives), use get_queried_object() and the appropriate meta function:
$current = get_queried_object();
$meta = get_term_meta( $current->term_id, 'header_image', true );
For per-post custom fields, those live inside the loop with get_post_meta().
What to do next
If your custom post type archive isn’t working, start with the simplest checks: is has_archive set to true, have you flushed rewrite rules, and is your template file named exactly archive-{post-type-slug}.php in the active theme root?
If the archive URL works but uses the wrong template, the template_include filter is the fastest way to see what WordPress is actually choosing. The result usually points directly at the naming or theme structure issue.
If you’re building from scratch, start with a copy of your theme’s archive.php as the foundation for archive-{post-type}.php. Modify from there rather than building completely from blank β the existing template handles the boilerplate you’d otherwise have to write twice.
The WordPress template hierarchy is one of the parts of the framework that becomes obvious once you’ve worked with it for a while and frustrating before that. The hierarchy itself is the answer to most “why isn’t my template loading” questions β knowing which files WordPress checks in which order resolves the issue in most cases without further debugging.
