Block themes with theme.json: templates, parts and child themes

Contents
- The files of a minimal block theme
- theme.json: settings, styles and version 3
- Templates, template parts and a pattern
- What the Site Editor writes to the database
- Template resolution: database, child theme, parent theme, fallback
- A child theme for a block theme
- How parent and child theme.json are merged
- Limits and open points
- Questions and answers
- Sources
A block theme mainly consists of HTML files containing block markup and a single JSON file. To understand how it works, you also need to know which template WordPress renders for a request, why a changed file sometimes has no visible effect, and what happens to the parent theme’s settings when a child theme supplies its own theme.json. A handful of functions in core determine this behavior.
This article builds a minimal but complete block theme with style.css, theme.json, a template, two template parts, a pattern and a style variation, and then a child theme on top of it. Along the way it follows the WordPress 7.1 source code to show where the Site Editor stores changes, in which order templates are resolved and how the data layers of global settings and styles are merged. The current release on 29.09.2026, according to the WordPress.org version-check API, is 7.1.2.
The files of a minimal block theme
WordPress treats a theme as a block theme once it finds a readable templates/index.html. WP_Theme::is_block_theme() checks exactly this path and, for backward compatibility, the older block-templates/index.html. Everything else is optional, but a usable theme needs a few more pieces. The folder of the example theme looks like this:
lw-base/
├── style.css
├── theme.json
├── templates/
│ └── index.html
├── parts/
│ ├── header.html
│ └── footer.html
├── patterns/
│ └── footer-credits.php
└── styles/
└── night.jsonThe style.css file carries the theme header. For a block theme it does not need any CSS at all, because the styles come from theme.json. The Theme Handbook lists Theme Name, Description, Version, Author, Text Domain, Tested up to, Requires at least, Requires PHP, License and License URI as required fields for the theme directory. The example sets Requires at least: 6.6 because it uses theme.json version 3, which was introduced with WordPress 6.6.
/*
Theme Name: LW Base
Theme URI: https://example.com/lw-base
Author: Lukas Wojcik
Author URI: https://lukaswojcik.com
Description: Minimal block theme with templates, template parts, a pattern and a style variation.
Version: 1.0.0
Requires at least: 6.6
Tested up to: 7.1
Requires PHP: 7.4
License: GNU General Public License v2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: lw-base
*/The folder names are fixed: templates go into templates/, template parts into parts/, patterns into patterns/ and style variations into styles/. The legacy names block-templates/ and block-template-parts/ are still recognized by WP_Theme::get_block_template_folders(), but only as a fallback for older themes.
theme.json: settings, styles and version 3
theme.json has two main sections. settings defines what the editor offers: color palettes, font sizes, font families, layout widths and which design tools are enabled. styles applies values to the whole page, to elements such as links and headings, and to individual blocks. In addition, the top level accepts customTemplates, templateParts and patterns; the JSON schema for 7.1 also lists title, slug, description and blockTypes, which are relevant for style variations.
{
"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
"version": 3,
"settings": {
"appearanceTools": true,
"layout": {
"contentSize": "720px",
"wideSize": "1200px"
},
"color": {
"defaultPalette": false,
"palette": [
{ "slug": "base", "color": "#ffffff", "name": "Base" },
{ "slug": "contrast", "color": "#111827", "name": "Contrast" },
{ "slug": "accent", "color": "#0369a1", "name": "Accent" }
]
},
"typography": {
"defaultFontSizes": false,
"fontSizes": [
{ "slug": "small", "size": "0.875rem", "name": "Small" },
{ "slug": "medium", "size": "1rem", "name": "Medium" },
{ "slug": "large", "size": "1.5rem", "name": "Large" }
],
"fontFamilies": [
{
"slug": "system",
"name": "System",
"fontFamily": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif"
}
]
}
},
"styles": {
"color": {
"background": "var(--wp--preset--color--base)",
"text": "var(--wp--preset--color--contrast)"
},
"typography": {
"fontFamily": "var(--wp--preset--font-family--system)",
"fontSize": "var(--wp--preset--font-size--medium)",
"lineHeight": "1.6"
},
"spacing": {
"padding": { "top": "1.5rem", "right": "1.5rem", "bottom": "1.5rem", "left": "1.5rem" }
},
"elements": {
"link": {
"color": { "text": "var(--wp--preset--color--accent)" }
}
}
},
"templateParts": [
{ "name": "header", "title": "Header", "area": "header" },
{ "name": "footer", "title": "Footer", "area": "footer" }
]
}The version number matters. The theme.json reference on developer.wordpress.org documents version 3, and the Block Editor Handbook states that it was introduced with WordPress 6.6. In the 7.1 source, WP_Theme_JSON::LATEST_SCHEMA is 3 as well. The dev note on make.wordpress.org describes the only breaking change: from version 3 onwards, a theme can no longer silently replace the default font sizes or spacing sizes by reusing their slugs. Anyone who wants to use slugs like small, medium and large for custom values has to set defaultFontSizes (or defaultSpacingSizes) to false, as the example does. According to the Block Editor Handbook, older versions remain supported.
For the $schema key there are two variants: https://schemas.wp.org/trunk/theme.json follows development, while https://schemas.wp.org/wp/7.1/theme.json is pinned to one release. The export function in core writes the versioned form, and that is also the safer choice for editor autocompletion because the trunk schema may already describe properties that the installed release does not know. All three JSON files in this article were validated against the 7.1 schema.
The templateParts array assigns each file in parts/ a title and an area. Core defines the areas header, footer and uncategorized; since 7.0, the source code also contains navigation-overlay. The area decides where the Site Editor lists the part and which HTML element wraps it when no tagName is set.
Templates, template parts and a pattern
A template is an HTML file with serialized blocks. The following index.html embeds the header and footer parts, wraps the content in a main element and uses the Query Loop block with "inherit":true, so that it shows whatever the main query of the current request contains: the blog index, an archive or search results.
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:query {"queryId":1,"query":{"perPage":10,"pages":0,"offset":0,"postType":"post","order":"desc","orderBy":"date","inherit":true}} -->
<div class="wp-block-query">
<!-- wp:post-template -->
<!-- wp:post-title {"isLink":true} /-->
<!-- 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"} /-->Template parts are referenced by slug. The attribute theme is deliberately missing. The render function of the Template Part block falls back to get_stylesheet(), the active theme. If a parent theme hard-coded its own slug there, the lookup would no longer match once a child theme is active, and the part would render as an empty string. Leaving the attribute out is what allows a child theme to replace the part.
<!-- wp:group {"layout":{"type":"flex","justifyContent":"space-between"}} -->
<div class="wp-block-group">
<!-- wp:site-title /-->
<!-- wp:navigation /-->
</div>
<!-- /wp:group --><!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:pattern {"slug":"lw-base/footer-credits"} /-->
</div>
<!-- /wp:group -->The footer does not contain its own text but loads a pattern. Pattern files in patterns/ are registered automatically on init, provided the file header has at least a Title and a Slug; core rejects files without them with a _doing_it_wrong() notice. Because the file is PHP, texts can be translated with the theme’s text domain, which is not possible in a plain HTML template.
/**
* Title: Footer credits
* Slug: lw-base/footer-credits
* Categories: footer
* Block Types: core/template-part/footer
* Description: Short credit line for the site footer.
*
* @package lw-base
*/
<!-- wp:paragraph {"fontSize":"small"} -->
<p class="has-small-font-size"> esc_html_e( 'Published with a block theme.', 'lw-base' ); </p>
<!-- /wp:paragraph -->A style variation is a partial theme.json in styles/. It only contains what differs from the main file. When a user selects it in the Site Editor, the Theme Handbook explains that its data is copied into the database as a user customization. Later updates of the variation file therefore do not reach sites that have already saved it.
{
"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
"version": 3,
"title": "Night",
"settings": {
"color": {
"palette": [
{ "slug": "base", "color": "#0a0e14", "name": "Base" },
{ "slug": "contrast", "color": "#e5e7eb", "name": "Contrast" },
{ "slug": "accent", "color": "#38bdf8", "name": "Accent" }
]
}
}
}What the Site Editor writes to the database
As long as nobody edits anything, WordPress reads the theme files directly. When a template is saved in the Site Editor, core stores the complete block markup as a post. Three post types are involved: wp_template for templates, wp_template_part for template parts and wp_global_styles for changes to global styles, including a selected style variation. The post_name of a template post is the template slug, for example index or single.
What ties these posts to a theme is the taxonomy wp_theme. Its term is the stylesheet, meaning the folder name of the active theme. With a child theme active, that is the child’s folder name, not the parent’s. get_block_templates() queries only posts whose term equals get_stylesheet(). Template parts additionally carry a term of the taxonomy wp_template_part_area. Internally, each template has the ID stylesheet//slug, for example lw-child//index.
The consequence is simple: a stored template wins over the file of the same slug. In get_block_templates(), the database result is fetched first, and its slugs are then passed as slug__not_in to the file lookup, so the theme file is no longer considered at all. Changes to templates/index.html made later by a theme update stay invisible until the customization is removed. The REST controller for templates only allows deleting templates whose source is custom; for a template based on a theme file, deleting the post restores the file version. To find out which customizations exist on a site, WP-CLI helps:
# Which templates, template parts and global styles live in the database?
wp post list --post_type=wp_template,wp_template_part,wp_global_styles --post_status=any --fields=ID,post_type,post_name,post_modified
# Which theme (stylesheet) does a customization belong to? 123 = an ID from the list above.
wp post term list 123 wp_theme --fields=nameBecause the query filters by the stylesheet term, a theme switch makes these posts inactive rather than removing them: they stay in the database under the old term and apply again once that theme is reactivated. The Site Editor export, implemented in wp_generate_block_templates_export_file(), writes a ZIP containing the theme files, the current templates and parts, and a theme.json in which the theme data and the user data are merged.
Template resolution: database, child theme, parent theme, fallback
The template hierarchy of block themes is the same as for classic themes; the Theme Handbook only notes that HTML files replace PHP files. For a single post, WordPress builds a list of candidate slugs such as single-post-hello-world, single-post, single, singular and index. locate_block_template() passes this list to resolve_block_template(), which in turn calls get_block_templates() with all candidate slugs and then sorts the results by their position in the hierarchy.
For each slug, the sources are consulted in a fixed order. First comes a wp_template post for the active stylesheet. Next, _get_block_templates_files() reads the child theme’s templates/ folder before the parent’s; a slug already found in the child is skipped in the parent. After that come templates that plugins register with register_block_template(), available since WordPress 6.7, but only if no theme file with the same slug exists. WordPress itself ships no HTML templates for themes, so the actual core fallback is the next, less specific slug in the hierarchy, ending with index.html.
The most specific slug always wins, regardless of its source. A single.html in the parent therefore beats an index.html customized in the Site Editor, because single comes before index in the hierarchy. The source order only decides between entries of the same slug. Template parts follow the same pattern: the render function of the Template Part block first looks for a wp_template_part post, then calls get_block_file_template(), which in turn uses _get_block_template_file() to search the stylesheet directory before the template directory.
The found block markup is rendered through wp-includes/template-canvas.php. For logged-in users, an empty stored template leads to a notice with a link to the Site Editor since 6.8; visitors instead see the content of the theme file, if there is one.
A child theme for a block theme
The only file a child theme strictly needs is style.css with a Template header. According to the Theme Handbook, its value must match the folder name of the parent theme exactly. The example child theme adds its own palette and a modified header.
lw-child/
├── style.css
├── theme.json
└── parts/
└── header.html/*
Theme Name: LW Child
Template: lw-base
Description: Child theme of LW Base with its own palette and header.
Version: 1.0.0
Requires at least: 6.6
Tested up to: 7.1
Requires PHP: 7.4
License: GNU General Public License v2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: lw-child
*/The child theme needs no templates/index.html of its own. WP_Theme::get_file_path() searches the stylesheet directory first and then the template directory, so is_block_theme() finds the parent’s file and treats the child as a block theme too. Everything that is missing in the child is taken from the parent: templates, template parts, patterns and style variations.
Overriding works per item. A file with the same name in templates/ or parts/ replaces the parent’s version. For patterns, the decisive factor in _register_theme_block_patterns() is not the file name but the Slug in the header: the child’s patterns are registered first, and a parent pattern with an already registered slug is skipped. For style variations, get_style_variations() compares file names in both styles/ folders and keeps only the child’s file on a match. The replaced header in the child looks like this:
<!-- wp:group {"layout":{"type":"flex","justifyContent":"space-between"}} -->
<div class="wp-block-group">
<!-- wp:group {"layout":{"type":"flex","orientation":"vertical"}} -->
<div class="wp-block-group">
<!-- wp:site-title /-->
<!-- wp:site-tagline /-->
</div>
<!-- /wp:group -->
<!-- wp:navigation /-->
</div>
<!-- /wp:group -->One practical detail follows from the stylesheet term. Customizations made in the Site Editor while the parent was active belong to the parent’s stylesheet. After switching to the child theme, they no longer apply; the child starts from the files.
How parent and child theme.json are merged
Global settings and styles come from four origins, which WP_Theme_JSON_Resolver::get_merged_data() merges in a fixed order: default (core’s own theme.json), blocks (data from block registrations), theme and custom (user data from the wp_global_styles post). Each later layer overrides the earlier ones. The code documentation gives the example of a background color for the Paragraph block set by both theme and user: the user preference wins.
The theme layer itself consists of several parts. get_theme_data() first reads the active theme’s theme.json, meaning the child’s. If a parent exists and has a different theme.json, the resolver creates a WP_Theme_JSON object from the parent file and merges the child data into it; the source comment says the child takes precedence. Finally, this result is merged onto values from add_theme_support(), so theme.json beats theme supports.
{
"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
"version": 3,
"settings": {
"color": {
"palette": [
{ "slug": "base", "color": "#f4f6f9", "name": "Base" },
{ "slug": "contrast", "color": "#111827", "name": "Contrast" },
{ "slug": "accent", "color": "#c2410c", "name": "Accent" }
]
}
},
"styles": {
"elements": {
"heading": {
"typography": { "fontWeight": "700" }
}
}
}
}The merge itself, WP_Theme_JSON::merge(), uses array_replace_recursive(), so nested objects are combined key by key. In the example, the parent’s layout, typography and link color are preserved, while the child adds the heading weight.
Presets are an exception: palettes, gradients, duotone filters, font sizes, font families and the other preset lists, such as spacing sizes and shadows, are replaced as whole lists. The child’s palette replaces the parent’s rather than extending it.
That is why the child repeats all three slugs. If it listed only accent, the colors base and contrast would be missing, and every style that references them would lose its value. You can check the effective theme palette on a test installation:
wp eval 'echo wp_json_encode( wp_get_global_settings( array( "color", "palette", "theme" ), array( "origin" => "base" ) ), JSON_PRETTY_PRINT );'The option "origin" => "base" leaves out the user layer, so the output shows the merged result of parent and child only.
Limits and open points
- The filter
wp_theme_json_data_themereceives the child’s data before the parent file is merged in. A callback that expects the complete theme data sees only the child part when a child theme is active. - Hybrid setups with PHP templates follow additional rules. If
locate_template()finds a PHP file, only block templates with equal or higher specificity are considered, and a PHP template in the child can take precedence over a block template of the same slug in the parent. - The Theme Handbook says patterns in a child theme are overridden by a file with the same name; the 7.1 source compares slugs. This article follows the source code.
- Some Handbook pages still show
"version": 2in their examples, while the reference documents version 3. - The examples were tested on WordPress 7.1.2 on 30.09.2026: both themes activate without notices, the child counts as a block theme without its own
index.html, the block markup validates in the Site Editor, and the template precedence, the effect of a theme switch and the palette replacement behave as described. The article on the WordPress 7.1.2 security fix for page templates covers a related topic.
Questions and answers
Does a child theme of a block theme need its own templates/index.html?
No. WP_Theme::is_block_theme() locates the file through get_file_path(), which checks the stylesheet directory first and the template directory second. The parent’s index.html is enough for the child to count as a block theme as well. A separate file is only needed when the child actually changes that template.
Why does a changed template file have no effect after a theme update?
Usually a Site Editor customization exists for the same slug. It is stored as a wp_template (or wp_template_part) post in the database and takes precedence, so the file is not read at all. wp post list --post_type=wp_template,wp_template_part shows such entries. Only after the customization is removed does the file version apply again.
Does the child theme’s color palette extend the parent’s palette?
No, it replaces it. WP_Theme_JSON::merge() combines nested objects key by key, but presets such as palettes, gradients, font sizes and font families are taken over as a complete list. The child therefore has to list every color that should remain available, including the unchanged ones.
Sources
- Theme Handbook: Introduction to templates (retrieved 29.09.2026)
- Theme Handbook: Template parts (retrieved 29.09.2026)
- Theme Handbook: Registering patterns (retrieved 29.09.2026)
- Theme Handbook: Style variations (retrieved 29.09.2026)
- Theme Handbook: Child themes (retrieved 29.09.2026)
- Theme Handbook: Template hierarchy (retrieved 29.09.2026)
- Theme Handbook: Main stylesheet (style.css) (retrieved 29.09.2026)
- Block Editor Handbook: Theme.json version 3 reference (retrieved 29.09.2026)
- Block Editor Handbook: Global settings and styles (theme.json) (retrieved 29.09.2026)
- Make WordPress Core: Theme.json version 3 (retrieved 29.09.2026)
- JSON schema theme.json for WordPress 7.1 (retrieved 29.09.2026)
- wordpress-develop 7.1: class-wp-theme-json-resolver.php (retrieved 29.09.2026)
- wordpress-develop 7.1: class-wp-theme-json.php (retrieved 29.09.2026)
- wordpress-develop 7.1: block-template-utils.php (retrieved 29.09.2026)
- wordpress-develop 7.1: block-template.php (retrieved 29.09.2026)
- wordpress-develop 7.1: blocks/template-part.php (retrieved 29.09.2026)
- wordpress-develop 7.1: block-patterns.php (retrieved 29.09.2026)
- wordpress-develop 7.1: class-wp-theme.php (retrieved 29.09.2026)
- wordpress-develop 7.1: class-wp-rest-templates-controller.php (retrieved 29.09.2026)
- WP-CLI: wp post list (retrieved 29.09.2026)
- WordPress.org version-check API (current release) (retrieved 29.09.2026)