Template Hierarchy in Block Themes: Templates, Parts and Where Overrides Live
How the block theme template hierarchy really resolves: file order, wp_template database overrides, template parts, areas and why your deployed edits vanish.
A client pinged us at 11pm: the footer on production still showed the old address. The change had been deployed, cached layers flushed, and the file on the server definitely contained the new text. We SSH’d in, cat‘d parts/footer.html, and the new address was right there. The site rendered the old one.
The answer was a single row in wpposts with posttype = 'wptemplatepart'. Somebody had opened the Site Editor six weeks earlier, nudged a padding value, and hit Save. From that moment the file on disk was dead weight. Nothing in the admin UI tells you this in a way a client will notice.
That’s the thing most explanations of the block theme template hierarchy skip. The resolution order isn’t just a list of filenames. It’s a three-layer lookup where the database sits above everything, and understanding that layer is the difference between a theme you can version control and one that silently drifts.
- Block themes resolve templates in this order: database (
wptemplate/wptemplatepartposts scoped by thewpthemetaxonomy), then child theme files, then parent theme files. Any Site Editor save creates a DB row that permanently shadows the file. - Only
templates/index.htmlis required. Everything else is the classic PHP hierarchy with.htmlinstead of.php, and the same specificity rules:single-product-widget.htmlbeatssingle-product.htmlbeatssingle.htmlbeatsindex.html. - Template parts are not PHP includes. Their
areavalue (header, footer, uncategorized) controls the semantic wrapper element the block emits and where they appear in the editor’s part list. - Database overrides never travel through git. If you deploy theme files to a site where editors have used the Site Editor, your changes do nothing until someone clicks Reset.
- Use the Create Block Theme plugin to pull DB customisations back into files before you commit, and audit
wp post list --posttype=wptemplateon every handover.
Two folders, one required file
A block theme’s structure is smaller than people expect. Here’s a real one, trimmed:
theme/
├── style.css # still required, still just the header comment
├── theme.json # version 3 since WP 6.6
├── functions.php # optional in a pure block theme
├── templates/
│ ├── index.html # the only mandatory template
│ ├── front-page.html
│ ├── single.html
│ ├── page.html
│ ├── archive.html
│ ├── search.html
│ ├── 404.html
│ └── page-no-title.html
├── parts/
│ ├── header.html
│ ├── header-minimal.html
│ ├── footer.html
│ └── post-meta.html
└── patterns/
├── hero-split.php
└── footer-columns.php
If you inherited a theme using block-templates/ and block-template-parts/, those are the 5.8 era names. They still work for backwards compatibility, but rename them. Mixing both folders in one theme produces resolution behaviour nobody wants to debug.
The .html extension is a lie, incidentally. These files are block markup: HTML comments carrying JSON attributes, parsed by parse_blocks() and rendered server side. You cannot put PHP in them. That constraint pushes anything dynamic into either a core block, a block binding, or a custom dynamic block registered from functions.php. That’s actually a healthier separation than the classic theme habit of scattering queries through template files.

How the hierarchy actually resolves
Core builds a candidate list in getquerytemplate() exactly as it always has, then locateblocktemplate() intercepts and looks for a block template matching each slug in order. For a single post in a product CPT with slug desk-lamp:
single-product-desk-lampsingle-productsinglesingularindex
For each slug, getblocktemplates() queries the wp_template post type first and falls back to theme files. So does a DB row named single beat a file named single-product? No. Specificity wins first, source second. Core walks the slug list in order, and for each slug checks DB then child then parent. A file-only single-product.html still beats a database-only single.
Two places this bites in practice. First, taxonomy templates: taxonomy-{taxonomy}-{term}.html then taxonomy-{taxonomy}.html then taxonomy.html then archive.html. If you’ve built a serious content model with several custom taxonomies, you’ll want these named consistently from day one. That’s much easier if you’ve already thought through your post types and taxonomies before writing a single template.
Second, front-page.html. It applies whether the front page is set to latest posts or a static page, sitting above both home.html and page.html. The only reliable escape is assigning an explicit custom template to that page in the editor sidebar, which current versions honour. Shipping front-page.html in a general purpose theme is usually the wrong choice. Ship a pattern instead and let the site owner build the front page as a page.
If you need to bend the order, the hierarchy filters still fire for block themes:
// Insert a brand-specific archive template above the generic one.
addfilter( 'taxonomytemplate_hierarchy', function ( $templates ) {
if ( is_tax( 'brand' ) ) {
array_unshift( $templates, 'archive-brand-grid' );
}
return $templates;
} );
That filter name is {$type}templatehierarchy, so singletemplatehierarchy, pagetemplatehierarchy and friends all exist. Add the slug to the array and core will look for both a DB row and templates/archive-brand-grid.html.
WordPress template parts are not includes
A classic theme’s gettemplatepart() is a glorified require. A block template part is a first class content object with an area, a title, and its own database shadow. You reference one like this:
<!-- wp:template-part {"slug":"header","area":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","area":"footer","tagName":"footer"} /-->
The area attribute does real work. It groups parts in the Site Editor sidebar, filters which patterns are offered when a user swaps the part, and sets the default wrapper element so a header area part emits <header> rather than a bare <div>. The practical values are header, footer and uncategorized. You can register more through the defaultwptemplatepartareas filter, but before you do, ask whether a named part with a clear title solves it.
Declare your parts in theme.json so they get human titles instead of filename slugs:
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"templateParts": [
{ "name": "header", "title": "Header (default)", "area": "header" },
{ "name": "header-minimal", "title": "Header (checkout)", "area": "header" },
{ "name": "footer", "title": "Footer", "area": "footer" },
{ "name": "post-meta", "title": "Post meta row", "area": "uncategorized" }
],
"customTemplates": [
{
"name": "page-no-title",
"title": "Page without title",
"postTypes": [ "page" ]
}
]
}
The customTemplates array populates the Template dropdown in the post editor sidebar. The name must match the filename without the extension. Omit the entry and your page-no-title.html exists but nobody can select it. That’s probably the support question we’ve answered more times than any other.
Where overrides live, and how to find them
Every Site Editor save writes a post. Templates go to wptemplate, parts to wptemplatepart: both private post types, both invisible in the admin menus, both assigned a term in the wptheme taxonomy equal to the theme’s stylesheet directory name.
That taxonomy term explains a behaviour that looks like data loss. Switch themes and all customisations vanish. Switch back and they return, untouched, because they were never deleted, just scoped to a term that stopped matching. The same applies to parent and child: a child theme gets its own term, so customisations made while the child is active do not apply if someone activates the parent directly.
To audit a site:
# Every customised template and part on this install
wp post list --posttype=wptemplate,wptemplatepart \
--poststatus=publish,draft --fields=ID,postname,posttype,postmodified
Which theme each one belongs to
wp post term list 142 wp_theme --fields=slug
Or straight SQL when you’re triaging someone else’s mess:
wp db query "SELECT ID, postname, posttype, post_modified
FROM wp_posts
WHERE posttype IN ('wptemplate','wptemplatepart')
AND post_status = 'publish'
ORDER BY post_modified DESC;"
In the Site Editor UI, overridden items carry a “Customized” label and a Reset action. Reset deletes the post and the file takes over again on the next request. Tell your clients this exists. Better: tell them which templates they’re allowed to touch, because “Reset” throws away work without a confirmation that explains what’s being thrown away.
Patterns as template content, and plugin-registered templates
Here’s the pattern that has survived the most client handovers for us: keep template files thin and push the actual layout into registered patterns.
<!-- wp:template-part {"slug":"header","area":"header","tagName":"header"} /-->
<!-- wp:pattern {"slug":"acme/archive-grid"} /-->
<!-- wp:template-part {"slug":"footer","area":"footer","tagName":"footer"} /-->
Patterns live in /patterns/*.php with a header comment block, which means they can contain PHP, translations and escaping. They also get header fields that plug directly into the editor:
<?php
/**
- Title: Archive grid
- Slug: acme/archive-grid
- Categories: query
- Block Types: core/query
- Template Types: archive, home, search
- Inserter: true
*/
?>
<!-- wp:query {"query":{"perPage":12,"postType":"post"},"layout":{"type":"default"}} -->
<!-- wp:post-template {"layout":{"type":"grid","columnCount":3}} -->
<!-- wp:post-featured-image {"isLink":true} /-->
<!-- wp:post-title {"isLink":true,"level":3} /-->
<!-- /wp:post-template -->
<!-- /wp:query -->
Template Types makes the pattern appear as a starting point when a user creates a template of that type. Block Types: core/template-part/header on a pattern makes it offerable as a header replacement. Both headers are the cheapest usability win available in a block theme, and almost nobody sets them.
One caveat on wp:pattern inside templates: in the Site Editor the pattern block renders read-only with a Detach option. Once a user detaches, the blocks are inlined into that template and your pattern updates stop reaching them. That’s a feature if you’re building for one client. It’s a problem if you ship updates to thousands of installs, which is a whole separate discipline we wrote about in shipping a commercial WordPress theme.
Since WordPress 6.7, plugins can register templates directly with registerblocktemplate(), slotting into the same hierarchy without touching the theme:
add_action( 'init', function () {
registerblocktemplate( 'acme-events//single-event', array(
'title' => __( 'Event', 'acme-events' ),
'description' => __( 'Single event layout shipped with the plugin.', 'acme-events' ),
// Plugin templates sit below theme files: a theme single-event.html still wins.
'content' => filegetcontents( __DIR__ . '/templates/single-event.html' ),
) );
} );
The double slash in the ID is deliberate: it’s plugin-slug//template-slug, mirroring the theme-slug//template-slug format core uses internally.
Deploying templates without wiping anyone’s work
Theme files are in git. Template overrides are in the database. Your deploy pipeline moves one and your content sync moves the other, usually in opposite directions. That’s the whole problem.
Three rules we apply on every block theme build:
- Audit before you deploy. Run the
wp post listquery above on production. If a template you’re about to change is customised, your change will not appear. Decide consciously whether to reset it. - Use Create Block Theme to round-trip. The plugin exports DB customisations back into
templates/andparts/files and clears the overriding posts. Run it on staging, commit the diff, deploy, and the site renders from files again. - Lock the Site Editor where it shouldn’t be used. Removing
editthemeoptionsfrom editor-level roles stops casual template edits on sites where the theme is the deliverable.
None of this works without a deployment story that treats the database as a first class artefact. Our staging and version control setup for WordPress teams covers the mechanics. If you’re starting a theme from scratch rather than retrofitting, the modern theme development workflow post covers the build tooling side.
If you’d rather not maintain any of this yourself, CanvasWP ships the template and part structure already wired up, which is roughly the point at which most agencies stop hand-rolling block themes for small projects.
Frequently Asked Questions
Why doesn’t my edited template file show up on the site?
Almost certainly because a database override exists for that template. Any save in the Site Editor creates a wptemplate or wptemplate_part post that shadows the file permanently. Open the Site Editor, find the template in the list, and use Reset, or delete the post via WP-CLI.
Can I still use PHP in block theme templates?
Not inside the .html files, which only accept block markup. You can use PHP in functions.php, in dynamic blocks you register yourself, and in pattern files under /patterns/, which are PHP and support translation functions and escaping. For values that need to come from the database, block bindings are usually a cleaner answer than a custom block.
Do child themes work the same way with block themes?
Yes, and better than in classic themes. A child theme’s templates/ and parts/ files override the parent file by file, and theme.json values merge rather than replace wholesale. Note that Site Editor customisations are scoped to the active theme via the wp_theme taxonomy, so changes made under the child do not apply if someone activates the parent.
How many template parts is too many?
Past roughly a dozen, the Site Editor’s part list becomes genuinely hard to navigate for non-technical users. We cap client themes at two headers, one or two footers, and a handful of uncategorized parts, then use patterns for everything else. Patterns are discoverable through the inserter; parts are not.
Should I add front-page.html to a theme I distribute?
Usually no. It takes precedence over both home.html and page.html, which surprises users who set a static front page and then cannot work out why their page content is ignored. Ship a front page pattern with a Template Types: front-page header instead and let people opt in.
If you take one action from this, make it the audit. Run wp post list --posttype=wptemplate,wptemplatepart against every block theme site you maintain, today, and compare the results against what’s in your repository. On most sites older than a year you’ll find at least one row you didn’t know about, and it will explain a bug somebody has already filed.


