Templates and Template Parts

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

A block theme has no PHP template files. Every screen of the site is described by an HTML file of block markup: the theme’s templates/ folder holds one file per kind of page (the blog index, a single post, a page, a book archive, the 404 page), and parts/ holds the pieces they share, such as the header and the footer. WordPress picks a template for the request, renders its blocks on the server and, when the site owner edits the template in the Site Editor, saves a copy in the database that takes precedence over the file. This page is for theme developers who write those files and for plugin developers who ship templates for their own post types. It builds the templates and parts of lumen, the block theme of the fictitious publisher Lumen Press, and the templates that its lumen-catalog plugin registers for the lp_book post type.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, theme.json version 3, and WP-CLI 2.12.0, and checked on 2026-10-28. The Gutenberg plugin (24.2.0) previews editor changes before they reach core; nothing here requires it. WordPress 7.2 is in beta and due in December 2026; its betas do not change the file format or the storage described here. Every listing and response below comes from a 7.1.3 site running the files on this page.

How WordPress chooses which template renders a request (the template hierarchy) is covered in Template Hierarchy; this page only names the files. The site owner’s side of editing templates is in Site Editor and Global Styles.

What templates and template parts are

Block themes arrived in WordPress 5.9 (January 2022) together with the Site Editor. A theme becomes a block theme when it contains templates/index.html; that single file is the minimum template, and it is how WordPress recognises the theme type and enables Appearance > Editor. Some older books place "full site editing" in 5.4 to 5.6; those releases only had Gutenberg plugin experiments. Other older books present templates only as PHP files with template tags, or describe the Site Editor only from the site owner’s point of view and never show the files behind it; both views still exist, but a block theme’s templates are the files described here.

Location Contents Notes

templates/*.html

Templates: complete page layouts, one per hierarchy slot (index, single, page, archive-lp_book, …​) or per custom template

index.html is required. File names follow the template hierarchy; custom template names must not reuse a hierarchy name.

parts/*.html

Template parts: reusable areas (header, footer, a sidebar)

Inserted with the Template Part block. Keep them directly in parts/; the Theme Handbook states that nested folders are not supported.

theme.json

templateParts and customTemplates metadata

Titles, areas and allowed post types. Version 3 since WordPress 6.6.

patterns/*.php

Patterns used inside templates

The place for translatable text and PHP logic, since .html files are not executed. See Theme Patterns.

block-templates/, block-template-parts/

Legacy folder names from the Gutenberg plugin era

Still read: if either folder exists, WordPress uses both legacy names instead of templates/ and parts/. Do not use them in new themes.

Templates and parts are plain HTML files, not PHP. Anything that must be computed (the current year, a translated string, a list of posts) comes from a dynamic block that renders on the server, or from a pattern referenced with <!-- wp:pattern {"slug":"lumen/..."} /-->, whose PHP runs when the template is rendered.

The complete theme used on this page has this layout:

wp-content/themes/lumen/
├── style.css
├── theme.json
├── functions.php          (optional: only for the custom area example below)
├── parts/
│   ├── header.html
│   └── footer.html
└── templates/
    ├── index.html
    ├── single.html
    ├── page.html
    ├── page-wide.html     (custom template)
    ├── archive-lp_book.html
    ├── single-lp_book.html
    ├── book-landing.html  (custom template)
    ├── 404.html
    └── search.html

style.css (name, Version: 0.1.0, Requires at least: 6.9, Requires PHP: 7.4, Text Domain: lumen) and the colour palette and layout sizes in theme.json are the ones shown in Themes Overview; this page adds only the template keys of theme.json.

Serialized block markup and its validity rules

A template is a sequence of blocks serialized as HTML with comment delimiters, the same format the block editor stores in post_content:

  • A block starts with <!-- wp:name {attributes} --> and ends with <!-- /wp:name -->. Core blocks omit the core/ namespace (wp:group), other blocks keep it (wp:lumen/book-card).

  • Attributes are a JSON object in the opening comment. It must be valid JSON: double-quoted keys and strings, no trailing commas, no comments.

  • A block with no inner HTML is self-closing: <!-- wp:post-title {"level":1} /-->. Dynamic blocks (Site Title, Post Title, Query Loop children, Navigation, Template Part) are written this way because their HTML is produced by PHP at render time.

  • A static block (Group, Columns, Heading, Paragraph) stores its HTML between the delimiters, and that HTML must be exactly what the block’s save() function produces for those attributes. A Group with "align":"full" must have the alignfull class on its <div>; a Column with "width":"33.33%" must carry style="flex-basis:33.33%"; a Heading must have class="wp-block-heading".

  • Whitespace and indentation between blocks are ignored, so template files can be indented for readability.

The front end does not validate anything: do_blocks() renders static blocks' stored HTML as written. The editor does. When the Site Editor loads a template whose static HTML differs from what save() would produce, it marks that block as invalid and offers to attempt recovery or convert it to HTML. A template that looks right on the site can therefore be broken for the site owner. The reliable way to produce valid markup is to build the layout in the Site Editor and copy it from the Code editor (More options > Code editor), or export it (see Exporting edits back to files), then tidy the indentation.

Rules that keep templates portable:

  • No hard-coded IDs. A Navigation block with {"ref":9}, an Image block with {"id":42} or a Query Loop taxQuery with term IDs refers to rows of one database. A theme ships a Navigation block without ref (it shows a fallback menu) and uses slugs or inherited queries instead of IDs.

  • No theme attribute on Template Part blocks. WordPress adds the active theme when it renders the block; a hard-coded "theme":"lumen" breaks the part in a child theme or a renamed copy.

  • No user-facing text that needs translation in .html files: it cannot pass through __(). Put it in a pattern and reference the pattern, or keep it as editable placeholder content as the examples below do.

  • No PHP, no <script>. The file is never executed, and scripts belong in blocks or enqueued assets.

  • The Post Content block in every single and page template. Without <!-- wp:post-content /--> the post’s own content is not shown.

Template parts

A template part is an HTML file in parts/ inserted with the Template Part block. The lumen header holds the site title and a Navigation block; the footer holds a line of text and the tagline:

<!-- parts/header.html -->
<!-- wp:group {"align":"full","layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull">
	<!-- wp:group {"align":"wide","layout":{"type":"flex","justifyContent":"space-between"}} -->
	<div class="wp-block-group alignwide">
		<!-- wp:site-title {"level":0} /-->
		<!-- wp:navigation {"overlayMenu":"mobile","layout":{"type":"flex","justifyContent":"right"}} /-->
	</div>
	<!-- /wp:group -->
</div>
<!-- /wp:group -->
<!-- parts/footer.html -->
<!-- wp:group {"align":"full","layout":{"type":"constrained"}} -->
<div class="wp-block-group alignfull">
	<!-- wp:paragraph -->
	<p>Lumen Press, independent publishers.</p>
	<!-- /wp:paragraph -->
	<!-- wp:site-tagline /-->
</div>
<!-- /wp:group -->

The leading <!-- parts/... --> comment is only a label on this page; the files start with the first block. A template inserts a part by slug with <!-- wp:template-part {"slug":"header","tagName":"header"} /-->.

The Template Part block’s attributes are slug (the file name without .html), tagName (header, footer, main, section, aside, article or div), area and theme. Without tagName, the element comes from the part’s area (area_tag below).

Areas and the templateParts key

Each part belongs to an area, which tells the editor what the part is for: the Site Editor groups parts by area, offers parts of the same area when the site owner replaces one, and uses block variations named after the area ("Header", "Footer"). The area is declared in theme.json:

{
	"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
	"version": 3,
	"settings": {
		"appearanceTools": true,
		"color": {
			"palette": [
				{ "slug": "paper", "name": "Paper", "color": "#fbf8f1" },
				{ "slug": "ink", "name": "Ink", "color": "#1f1d1a" },
				{ "slug": "lumen-red", "name": "Lumen red", "color": "#a3271f" }
			]
		},
		"layout": {
			"contentSize": "680px",
			"wideSize": "1140px"
		}
	},
	"styles": {
		"color": {
			"background": "var(--wp--preset--color--paper)",
			"text": "var(--wp--preset--color--ink)"
		}
	},
	"templateParts": [
		{ "name": "header", "title": "Header", "area": "header" },
		{ "name": "footer", "title": "Footer", "area": "footer" }
	],
	"customTemplates": [
		{ "name": "page-wide", "title": "Page: wide, no title", "postTypes": [ "page" ] },
		{ "name": "book-landing", "title": "Book: landing page", "postTypes": [ "lp_book" ] }
	]
}

Each templateParts entry has name (the file name without extension), title (shown in the editor and translatable through the theme’s text domain) and area. WordPress 7.1.3 allows four areas:

area Label Default wrapper (area_tag) Use

header

Header

header

Site title, logo, main navigation

footer

Footer

footer

Credits, secondary links

uncategorized

General

div

Anything else: a sidebar, a "book details" box

navigation-overlay

Navigation Overlay

div

The block-based mobile menu overlay of the Navigation block (added in 7.0)

A part that is not listed in templateParts, or that names an area outside this list, is filed under uncategorized. With WP_DEBUG on, an unknown area also raises a notice ("is not a supported wp_template_part area value"). Core defines a WP_TEMPLATE_PART_AREA_SIDEBAR constant, but sidebar is not one of the allowed areas, so "area": "sidebar" silently becomes General unless a filter adds it.

Adding an area

The default_wp_template_part_areas filter (since 5.9) adds areas. Each needs area, label, description, icon and area_tag. Lumen adds a Sidebar area for book filters:

<?php
/**
 * Lumen theme functions.
 *
 * @package Lumen
 */

/**
 * Adds a "Sidebar" area for template parts.
 *
 * @param array[] $areas Allowed template part areas.
 * @return array[] Areas including the sidebar.
 */
function lumen_template_part_areas( array $areas ) {
	$areas[] = array(
		'area'        => 'sidebar',
		'area_tag'    => 'aside',
		'label'       => __( 'Sidebar', 'lumen' ),
		'description' => __( 'Secondary content next to the main column, such as book filters.', 'lumen' ),
		'icon'        => 'sidebar',
	);
	return $areas;
}
add_filter( 'default_wp_template_part_areas', 'lumen_template_part_areas' );

With { "name": "book-filters", "title": "Book filters", "area": "sidebar" } added to templateParts and a parts/book-filters.html file, the part is listed with area sidebar, and get_allowed_block_template_part_areas() returns {"uncategorized":"div","header":"header","footer":"footer","navigation-overlay":"div","sidebar":"aside"} as its area tags. The Theme Handbook lists div, article, aside, footer, header, main and section as valid area_tag values, and only the header, footer and sidebar icons have their own artwork. An area that the active theme adds disappears with the theme, and parts already saved with it fall back to General; an area meant to outlive the theme belongs in a plugin.

The Lumen templates

Each template below starts with the header part, wraps the page body in a Group rendered as <main>, and ends with the footer part. Which file serves which request is the template hierarchy’s job; in short, index.html is the final fallback, single.html and page.html serve posts and pages, archive-lp_book.html and single-lp_book.html are the post-type-specific versions for books (the lp_book type registers has_archive, so /books/ is its archive), search.html serves ?s= requests and 404.html serves requests that match nothing.

index.html: the fallback and the blog

<!-- wp:template-part {"slug":"header","tagName":"header"} /-->

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
	<!-- wp:query {"queryId":1,"query":{"inherit":true,"perPage":10}} -->
	<div class="wp-block-query">
		<!-- wp:post-template -->
			<!-- wp:post-title {"isLink":true} /-->
			<!-- wp:post-date /-->
			<!-- wp:post-excerpt /-->
		<!-- /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>Nothing has been published yet.</p>
			<!-- /wp:paragraph -->
		<!-- /wp:query-no-results -->
	</div>
	<!-- /wp:query -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->

single.html and page.html

<!-- templates/single.html -->
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
	<!-- wp:post-title {"level":1} /-->
	<!-- wp:group {"layout":{"type":"flex"}} -->
	<div class="wp-block-group">
		<!-- wp:post-date /-->
		<!-- wp:post-author-name /-->
		<!-- wp:post-terms {"term":"category"} /-->
	</div>
	<!-- /wp:group -->
	<!-- wp:post-featured-image /-->
	<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
	<!-- wp:post-navigation-link {"type":"previous"} /-->
	<!-- wp:post-navigation-link /-->
	<!-- wp:comments -->
	<div class="wp-block-comments">
		<!-- wp:comments-title /-->
		<!-- wp:comment-template -->
			<!-- wp:comment-author-name /-->
			<!-- wp:comment-content /-->
		<!-- /wp:comment-template -->
		<!-- wp:post-comments-form /-->
	</div>
	<!-- /wp:comments -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->

page.html is the same layout without the date, author, terms, post navigation and comments: header part, Post Title, Post Featured Image, Post Content and footer part.

archive-lp_book.html and single-lp_book.html

The catalogue archive shows books in a three-column grid with cover, title and genre; the single book view puts the cover beside the title, series, genre and description, and lists three more books:

<!-- templates/archive-lp_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","showPrefix":false} /-->
	<!-- wp:query {"queryId":2,"query":{"inherit":true,"perPage":12},"align":"wide"} -->
	<div class="wp-block-query alignwide">
		<!-- wp:post-template {"layout":{"type":"grid","columnCount":3}} -->
			<!-- wp:post-featured-image {"isLink":true,"aspectRatio":"2/3"} /-->
			<!-- wp:post-title {"level":2,"isLink":true} /-->
			<!-- wp:post-terms {"term":"lp_genre"} /-->
		<!-- /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>The catalogue is empty.</p>
			<!-- /wp:paragraph -->
		<!-- /wp:query-no-results -->
	</div>
	<!-- /wp:query -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
<!-- templates/single-lp_book.html -->
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
	<!-- wp:columns {"align":"wide"} -->
	<div class="wp-block-columns alignwide">
		<!-- wp:column {"width":"33.33%"} -->
		<div class="wp-block-column" style="flex-basis:33.33%">
			<!-- wp:post-featured-image {"aspectRatio":"2/3"} /-->
		</div>
		<!-- /wp:column -->
		<!-- wp:column {"width":"66.66%"} -->
		<div class="wp-block-column" style="flex-basis:66.66%">
			<!-- wp:post-title {"level":1} /-->
			<!-- wp:post-terms {"term":"lp_series","prefix":"Series: "} /-->
			<!-- wp:post-terms {"term":"lp_genre"} /-->
			<!-- wp:post-content /-->
		</div>
		<!-- /wp:column -->
	</div>
	<!-- /wp:columns -->
	<!-- wp:heading -->
	<h2 class="wp-block-heading">More from the catalogue</h2>
	<!-- /wp:heading -->
	<!-- wp:query {"queryId":3,"query":{"postType":"lp_book","perPage":3,"inherit":false}} -->
	<div class="wp-block-query">
		<!-- wp:post-template {"layout":{"type":"grid","columnCount":3}} -->
			<!-- wp:post-title {"level":3,"isLink":true} /-->
		<!-- /wp:post-template -->
	</div>
	<!-- /wp:query -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->

The ISBN, page count and publication date are post meta; showing them in a template is the job of block bindings (Block Bindings API) or of the lumen/book-card block, not of plain template markup.

404.html and search.html

404.html replaces the Query Loop with a level-1 Heading ("Page not found"), a Paragraph suggesting a search, and a Search block (<!-- wp:search {"label":"Search","buttonText":"Search"} /-->). WordPress still sends the 404 status code; the template only controls the body.

search.html is index.html with two additions above the Query Loop: a Query Title block with {"type":"search"} ("Search results for: …​") and a Search block, so that visitors can refine the query; its No Results block says that nothing matched.

The front-end search covers every public post type, so books appear in the results; the lp_book type would be excluded only if it set exclude_from_search. All the template and part files on this page parse as valid blocks with the core block library, which is the same check the Site Editor performs when it opens them.

The Query Loop and post blocks

The Query Loop block (core/query) is how a template lists posts. Its children repeat or wrap the list: Post Template (core/post-template) repeats its inner blocks once per post, Pagination and its Previous, Numbers and Next blocks page through the results, and No Results renders only when the query is empty. Query Title shows "Books", "Search results for: …​" or an author name, depending on its type.

Attribute Meaning

queryId

Distinguishes several loops on one page; each non-inherited loop paginates with its own query-N-page URL parameter

query.inherit

true: use the main query WordPress already ran for the URL (the archive, the search, the blog). false: run a separate query from the attributes below.

query.postType, perPage, offset, pages

Post type, items per page, items to skip, maximum pages (0 = no limit)

query.order, orderBy

desc/asc; date, title, and others

query.author, search, exclude, sticky, parents, taxQuery

Filters. taxQuery takes term IDs keyed by taxonomy, which ties the template to one database.

tagName

Wrapper element (div by default)

enhancedPagination

Client-side pagination without a full page reload (Interactivity API, 6.4)

namespace

Marks a block variation that a plugin built on the Query Loop

inherit: true is what makes one template serve many URLs: archive-lp_book.html lists books at /books/, index.html lists posts on the blog, and search.html lists results, without any of them naming a post type. With an inherited query the perPage attribute is not used; the page size comes from Settings > Reading ("Blog pages show at most", the posts_per_page option), and changing it changes the archive. To change the main query (for example, 12 books per page on /books/ only), hook pre_get_posts in a plugin rather than switching the template to a custom query, which would run a second query and break pagination. inherit: false, as in the "More from the catalogue" loop, is for secondary lists.

The post blocks render one field of the current post, inside a Post Template or in a single template:

Block Renders

post-title

The title, optionally as a link (isLink) and with a heading level

post-content

The post’s own blocks; required in single, page and custom templates

post-excerpt

The excerpt or a trimmed version of the content, with an optional "Read more" link

post-featured-image

The featured image (thumbnail support), with aspectRatio, isLink and size settings

post-date, post-author-name, post-author

Publication or modified date, author name or author box

post-terms

The post’s terms in the taxonomy named by term (category, post_tag, lp_genre, lp_series), with prefix, suffix and separator

post-navigation-link

Link to the previous or next post

read-more

A link to the post, for use inside Post Template

comments (with comments-title, comment-template, post-comments-form)

The comment list and form

A Post Terms block for a taxonomy that the post type does not use renders nothing; it does not fail. A block that depends on a post type supports value (featured image, excerpt, comments) also renders nothing when the type lacks that support, which is why lp_book registers excerpt and thumbnail.

Custom templates

A custom template is a template that is not part of the hierarchy and that an editor chooses per post in the Template panel of the post editor. Older books call them "page templates" and create them with a Template Name: comment in a PHP file; block themes list them in customTemplates in theme.json (shown above), with the file in templates/. Each entry has name (file name without .html), title and optionally postTypes, which defaults to page only. Lumen ships two:

page-wide.html is page.html without the Post Title, with the main Group and the Post Content set to "align":"full" and the default (unconstrained) layout. The book template:

<!-- templates/book-landing.html: a book's campaign page -->
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->

<!-- wp:group {"tagName":"main","align":"full","layout":{"type":"constrained"}} -->
<main class="wp-block-group alignfull">
	<!-- wp:post-featured-image {"align":"wide","aspectRatio":"16/9"} /-->
	<!-- wp:post-title {"level":1,"textAlign":"center"} /-->
	<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->

The choice is stored in the post’s _wp_page_template meta as the template slug (page-wide), the same meta that classic page templates use, and wp post update <id> --page_template=page-wide sets it from WP-CLI. A custom template whose name matches a hierarchy file (single, archive) would be confused with it, and a custom template missing from customTemplates still works but shows its slug as title and is offered for pages only.

Templates from plugins: register_block_template()

A plugin that adds a post type usually wants it to look right in any block theme, not only in one built for it. Before WordPress 6.7 that meant filtering get_block_templates and pre_get_block_file_template. Since 6.7, register_block_template() registers a template with a name of the form plugin-slug//template-slug, and unregister_block_template() removes it. The lumen-catalog plugin registers a single book template, used when the active theme has none, and a custom "printable sheet" template offered for books:

<?php
/**
 * Plugin Name:       Lumen Catalog
 * Description:       Book catalogue of Lumen Press: the lp_book post type, its taxonomies, meta and templates.
 * Version:           0.1.0
 * Requires at least: 6.9
 * Requires PHP:      7.4
 * License:           GPL-2.0-or-later
 * Text Domain:       lumen-catalog
 *
 * @package LumenCatalog
 */

defined( 'ABSPATH' ) || exit;

// lp_book, lp_genre and lp_series are registered as shown in Custom Post Types and Taxonomies;
// lp_book uses 'has_archive' => true, 'rewrite' => array( 'slug' => 'books' ) and supports
// title, editor, excerpt, thumbnail and custom-fields.

/**
 * Registers the plugin's block templates (WordPress 6.7+).
 */
function lumen_catalog_register_templates() {
	if ( ! function_exists( 'register_block_template' ) ) {
		return;
	}
	register_block_template(
		'lumen-catalog//single-lp_book',
		array(
			'title'       => __( 'Single book', 'lumen-catalog' ),
			'description' => __( 'Displays one book of the catalogue.', 'lumen-catalog' ),
			'content'     => lumen_catalog_template_content( 'single-lp_book' ),
		)
	);
	register_block_template(
		'lumen-catalog//book-print',
		array(
			'title'       => __( 'Book: printable sheet', 'lumen-catalog' ),
			'description' => __( 'A sheet without header and footer, for press kits.', 'lumen-catalog' ),
			'content'     => lumen_catalog_template_content( 'book-print' ),
			'post_types'  => array( 'lp_book' ),
		)
	);
}
add_action( 'init', 'lumen_catalog_register_templates' );

/**
 * Reads a template's block markup from the plugin's templates directory.
 *
 * @param string $slug Template slug, without extension.
 * @return string Block markup, or an empty string if the file is missing.
 */
function lumen_catalog_template_content( $slug ) {
	$file = __DIR__ . '/templates/' . sanitize_file_name( $slug ) . '.html';
	if ( ! is_readable( $file ) ) {
		return '';
	}
	return (string) file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
}

The markup lives in wp-content/plugins/lumen-catalog/templates/, so it can be edited and validated like a theme file. Its single-lp_book.html is a plain version of the theme’s (header part, Post Title, Post Featured Image, Post Terms for lp_genre, Post Content, footer part); book-print.html drops the header and footer:

<!-- templates/book-print.html (plugin) -->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
	<!-- wp:post-title {"level":1} /-->
	<!-- wp:post-terms {"term":"lp_series","prefix":"Series: "} /-->
	<!-- wp:post-excerpt /-->
	<!-- wp:post-content /-->
</main>
<!-- /wp:group -->

How the registry behaves, as checked on 7.1.3:

  • $args takes title, description, content, post_types and plugin. A template whose slug is a hierarchy name (single-lp_book) joins the hierarchy; one with post_types becomes a custom template offered for those types.

  • The name must be lowercase and contain the // separator; otherwise the function returns a WP_Error (template_name_no_uppercase, template_no_prefix, template_already_registered) and triggers _doing_it_wrong().

  • The theme wins. When the active theme has a file with the same slug, the theme file is used; the template only records which plugin also provides it. With lumen active, single-lp_book is served from the theme; with Twenty Twenty-Five, which has no book template, the plugin’s version is served instead of the theme’s generic single.html.

  • Only templates can be registered, not template parts. The plugin template above uses the theme’s header and footer parts by slug, which every well-built block theme provides.

  • Plugin templates appear in the Site Editor like theme templates and can be customized there; the customization is stored as described in the next section.

Activating Lumen and checking the output

The examples run on the Compose stack of Local Development Environments (WordPress 7.1.3 with Apache on port 8080, MariaDB 11.8 and a wordpress:cli-2.12.0-php8.4 service named cli), with the theme and the plugin bind-mounted into both the wordpress and the cli services:

    volumes:
      - wp:/var/www/html
      - ./wp-content/themes/lumen:/var/www/html/wp-content/themes/lumen
      - ./wp-content/plugins/lumen-catalog:/var/www/html/wp-content/plugins/lumen-catalog
export WP_DB_PASSWORD='<a-local-db-password>' WP_ADMIN_PASSWORD='<a-local-admin-password>'
docker compose up -d
docker compose exec cli wp core install --url=http://lumenpress.test:8080 --title="Lumen Press" \
  --admin_user=admin --admin_email=admin@lumenpress.test --admin_password="$WP_ADMIN_PASSWORD" --skip-email
docker compose exec cli sh -c '
  wp plugin activate lumen-catalog && wp theme activate lumen
  wp rewrite structure "/%postname%/" && wp option update blogdescription "Independent publishers since 2009"
  wp term create lp_genre "Science Fiction" --slug=science-fiction
  wp term create lp_series "The Tidewater Cycle" --slug=tidewater
  for t in "Salt and Signal" "The Lantern Keeper" "Northern Glass"; do
    id=$(wp post create --post_type=lp_book --post_status=publish --post_title="$t" --porcelain)
    wp post term set "$id" lp_genre science-fiction && wp post term set "$id" lp_series tidewater
  done
  wp post create --post_type=page --post_status=publish --post_title=About'

The resolved templates, their source and the plugin that also provides them, come from get_block_templates():

<?php
// list-templates.php: run with "wp eval-file list-templates.php".
foreach ( array( 'wp_template', 'wp_template_part' ) as $type ) {
	foreach ( get_block_templates( array(), $type ) as $t ) {
		WP_CLI::log(
			sprintf(
				'%-17s %-28s %-7s %-14s %s',
				$type,
				$t->id,
				$t->source,
				$t->plugin ?? '',
				'wp_template_part' === $type ? $t->area : implode( ',', (array) $t->post_types )
			)
		);
	}
}
wp_template       lumen//single                theme
wp_template       lumen//page                  theme
wp_template       lumen//index                 theme
wp_template       lumen//archive-lp_book       theme
wp_template       lumen//book-landing          theme                  lp_book
wp_template       lumen//404                   theme
wp_template       lumen//page-wide             theme                  page
wp_template       lumen//search                theme
wp_template       lumen//single-lp_book        theme   lumen-catalog
wp_template       lumen//book-print            plugin  lumen-catalog  lp_book
wp_template_part  lumen//footer                theme                  footer
wp_template_part  lumen//header                theme                  header

Template IDs have the form theme//slug, even for plugin templates: lumen//book-print has source plugin, while lumen//single-lp_book comes from the theme file and only notes that lumen-catalog registers the same slug. Running the same script with Twenty Twenty-Five active lists twentytwentyfive//single-lp_book with source plugin.

The rendered pages show which template answered each URL (--resolve maps the host name without editing /etc/hosts):

for u in / /books/ /books/salt-and-signal/ /about/ /no-such-page/ "/?s=lantern"; do
  printf '%s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' --resolve lumenpress.test:8080:127.0.0.1 \
    "http://lumenpress.test:8080$u")"
  curl -s --resolve lumenpress.test:8080:127.0.0.1 "http://lumenpress.test:8080$u" \
    | grep -oE '<h1[^>]*>[^<]*|More from the catalogue|catalogue is empty|wp-block-search__input' | sort -u
done
/ 200
/books/ 200
<h1 class="wp-block-query-title">Books
/books/salt-and-signal/ 200
<h1 class="wp-block-post-title">Salt and Signal
More from the catalogue
/about/ 200
<h1 class="wp-block-post-title">About
/no-such-page/ 404
<h1 class="wp-block-heading">Page not found
wp-block-search__input
/?s=lantern 200
<h1 class="wp-block-query-title">Search results for: &#8220;lantern&#8221;
wp-block-search__input

Every page also starts with <header class="wp-block-template-part"> and ends with <footer class="wp-block-template-part">: the tagName of the Template Part blocks. The blog index has no <h1> because index.html has no Query Title. Assigning the custom templates changes the output of single URLs:

docker compose exec cli wp post update "$(docker compose exec -T cli wp post list --post_type=page --name=about --field=ID)" --page_template=page-wide
docker compose exec cli wp post update "$(docker compose exec -T cli wp post list --post_type=lp_book --name=the-lantern-keeper --field=ID)" --page_template=book-print

After that, /about/ renders without a title (page-wide), and /books/the-lantern-keeper/ renders the plugin’s printable sheet: the title and "Series: The Tidewater Cycle" with no <header> element, because book-print has no header part.

How edited templates are stored

The Site Editor never writes to the theme folder. When the site owner saves a template or a part, WordPress creates a post with the template’s markup, and from then on that post wins over the file:

Item Storage

Edited or new template

A wp_template post. post_name is the slug (single-lp_book), post_content the full markup, post_title the title.

Edited or new template part

A wp_template_part post with the same fields, plus a wp_template_part_area term (header, footer, uncategorized, navigation-overlay, or a filtered area).

Theme binding

A wp_theme term named after the theme directory (lumen). An edit belongs to one theme: switching themes hides it; switching back restores it.

Origin

An origin post meta (theme or plugin) recording where the edited template came from.

History

Both post types support revisions; the Site Editor shows them under Revisions.

Permissions

Creating, editing and deleting requires edit_theme_options (Administrators).

The REST API describes the result in each template’s fields: source is theme, plugin or custom (edited), origin says where a customized template came from, wp_id is the post ID, has_theme_file tells whether a file of that slug exists (and therefore whether "Reset" is possible, or only "Delete"), and is_custom marks custom templates. The endpoints are /wp/v2/templates/<theme>//<slug> and /wp/v2/template-parts/<theme>//<slug>.

Seeing an edit in the database

The script below saves the header part and the book template through the same REST endpoints the Site Editor calls, in process with rest_do_request(), so no browser or application password is needed:

<?php
/**
 * Saves the header template part and the book template the way the Site Editor does.
 * Run with: wp eval-file edit-templates.php --user=admin
 */
$header  = get_block_template( 'lumen//header', 'wp_template_part' );
$content = str_replace(
	'<!-- wp:site-title {"level":0} /-->',
	"<!-- wp:site-title {\"level\":0} /-->\n\t\t<!-- wp:site-tagline /-->",
	$header->content
);

$request = new WP_REST_Request( 'POST', '/wp/v2/template-parts/lumen//header' );
$request->set_body_params( array( 'content' => $content ) );
$saved = rest_do_request( $request )->get_data();
WP_CLI::log( "header: source {$saved['source']}, wp_id {$saved['wp_id']}, area {$saved['area']}" );

$request = new WP_REST_Request( 'POST', '/wp/v2/templates/lumen//single-lp_book' );
$request->set_body_params( array( 'title' => 'Single book (edited)' ) );
$saved = rest_do_request( $request )->get_data();
WP_CLI::log( "single-lp_book: source {$saved['source']}, wp_id {$saved['wp_id']}" );
docker compose exec cli wp eval-file edit-templates.php --user=admin
docker compose exec cli wp post list --post_type=wp_template,wp_template_part --post_status=any \
  --fields=ID,post_type,post_name,post_title
docker compose exec cli wp post term list 9 wp_theme wp_template_part_area --fields=taxonomy,slug
docker compose exec cli wp post meta list 10
header: source custom, wp_id 9, area header
single-lp_book: source custom, wp_id 10
ID	post_type	post_name	post_title
9	wp_template_part	header	Header
10	wp_template	single-lp_book	Single book (edited)
taxonomy	slug
wp_template_part_area	header
wp_theme	lumen
post_id	meta_key	meta_value
10	origin	theme

The IDs depend on what the site already contains. Saving only the title of single-lp_book copied the whole template into post_content: from that moment the file is no longer read for that template, even though its markup was not touched. The rendered pages now show the tagline twice (in the edited header and in the footer).

Theme updates do not reach edited templates

If a new version of the theme changes parts/header.html (for example, adding a paragraph), a site with a customized header keeps rendering its database copy: the curl output above stays the same and the new paragraph does not appear. This is intended, since the site owner’s work must survive theme updates, and it is the most common support question about block themes. The fix is a reset, which deletes the post; the Site Editor’s "Reset" does the equivalent of:

<?php
/**
 * Resets the customized header to the theme file, like "Reset" in the Site Editor.
 * Run with: wp eval-file reset-header.php --user=admin
 */
$request = new WP_REST_Request( 'DELETE', '/wp/v2/template-parts/lumen//header' );
$request->set_param( 'force', true );
$response = rest_do_request( $request );
WP_CLI::log( 'status ' . $response->get_status() . ', deleted: ' . var_export( $response->get_data()['deleted'], true ) );
WP_CLI::log( 'header source now: ' . get_block_template( 'lumen//header', 'wp_template_part' )->source );
status 200, deleted: true
header source now: theme

After the reset the page renders the updated theme file, including the new paragraph, and the owner’s tagline edit is gone. A theme that changes a part’s structure in an update should say so in its changelog, so that owners with customized copies can reset or merge by hand.

Exporting edits back to files

To move a design from the database into the theme (for a release, or to put it under version control), the Site Editor’s More options > Tools > Export builds a ZIP of the whole theme with every customized template and part written to templates/ and parts/; wp_generate_block_templates_export_file() is the function behind it. The Create Block Theme plugin can save changes directly into the theme folder and then clear the database copies. Both are covered in Create Block Theme and Version Control. An export keeps Navigation "ref" IDs, which must be removed before the markup is shipped in a theme.

What older books get wrong or leave out

Older claim or gap Current behaviour

Full site editing arrived in WordPress 5.4 to 5.6

Block themes, block templates and the Site Editor arrived in 5.9; earlier releases only had Gutenberg plugin experiments.

Full site editing is still maturing and best avoided in favour of a Customizer-based theme

Block themes have been the default since Twenty Twenty-Two; templates, parts, custom templates and plugin templates are stable APIs.

Templates are PHP files with template tags and The Loop

That is still true for classic themes (Classic Theme Anatomy and The Loop); block themes use .html files of block markup and the Query Loop block.

Page templates are declared with a Template Name: header

Block themes declare custom templates in customTemplates, with optional postTypes; the post’s _wp_page_template meta still stores the choice.

A site owner’s template edits change the theme

Edits are wp_template and wp_template_part posts; the theme files change only through an export or Create Block Theme, and theme updates do not override customized templates.

Plugins customise a post type’s display with single_template filters and PHP files

With a block theme, a plugin registers block templates with register_block_template() (6.7), and the theme can override them.

Common errors and pitfalls

Symptom Cause and fix

"Block contains unexpected or invalid content." in the Site Editor, while the front end looks fine

Hand-written static HTML does not match the block’s save() output (missing alignfull, wp-block-heading, a flex-basis style). Rebuild the block in the editor and copy the Code editor output.

Header or footer area shows "Template part has been deleted or is unavailable"

The slug has no file in parts/ (typo, or a part kept in a subfolder), or a hard-coded theme attribute names another theme.

A part is listed under General although theme.json says "area": "sidebar"

sidebar is not an allowed area; add it with default_wp_template_part_areas or use uncategorized.

A custom template is not offered for books

postTypes missing (it defaults to page) or a typo in the post type name.

The single template shows the title but not the text

No Post Content block in the template.

/books/ uses index.html or returns 404

The post type lacks has_archive, or rewrite rules were not flushed after registering it (wp rewrite flush).

Archive shows 10 items although the Query Loop says 12

The loop inherits the main query; the page size is the Reading setting. Use pre_get_posts to change it for the archive.

Theme update has no visible effect

The site has a customized copy of the template or part. Compare in Appearance > Editor (customized items are marked) and reset or merge.

Translated sites still show English text from a template

Text in .html files is not translatable; move it into a pattern that uses esc_html__().

register_block_template() returns WP_Error

Uppercase letters, a missing // prefix, or the same name registered twice (for example on every init of a long-running process).