Block themes and theme.json
What a Block Theme Actually Is on Disk
Which files make WordPress treat a directory as a block theme, what the Site Editor can change, and why style.css stopped being the main stylesheet.
You install a theme, activate it, and go looking for the Site Editor. It is not in the admin menu. Or you find it, open the Styles screen, and the colours you came for are locked behind a settings panel that was never designed for them. Both outcomes come from the same place: the theme directory does not contain the files WordPress looks for, so WordPress is running it as a classic theme with your hopes attached.
Block themes are not classic themes with a JSON file bolted on. The two are detected differently, styled differently and edited differently. Once you know the exact test WordPress applies, the confusing failures turn into a short checklist, and the Site Editor stops being a mystery box that sometimes works.
WordPress looks for one file, not for a feature list
The detection is blunt. WordPress reads the active theme's directory and checks
whether templates/index.html exists and is readable. If it does, the theme
is a block theme. If it does not, the theme is a classic theme, and no amount of
theme.json changes that verdict.
The older location block-templates/index.html still counts, which matters
if you are reading themes written before the rename. New themes use
templates/. Everything else in the block theme feature set hangs off that
single file existing on disk.
A theme can therefore ship a perfectly good theme.json, render through
the block editor, and still refuse to open the Site Editor. The JSON is read by the style
engine regardless of theme type, so your palette prints and your fonts apply, but the
template canvases never appear because WordPress never entered the block theme code path.
The symptom looks like a broken file. The cause is a missing one.
The minimum file set
Three files make a valid block theme: style.css with a theme header,
templates/index.html with at least one block, and optionally
theme.json. Only the first two affect detection. A theme without
theme.json is a block theme that inherits core defaults for colour,
typography and spacing, which is rarely what you want but is a legal place to be.
A block template is data, so the file extension is the difference
In a classic theme, index.php is PHP that runs on every request: you call
get_header(), loop over posts, and print whatever markup you like. In a block
theme, templates/index.html holds block markup. WordPress parses it and
renders each block through the server-side renderer, and the same file is what the Site
Editor loads into its canvas.
That dual life is the whole point of the format. A template is not code that produces
markup, it is a record of which blocks make up the page, stored in the shape both the
editor and the front end agree on. Here is a complete
templates/index.html:
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:query {"query":{"perPage":10,"postType":"post"}} -->
<div class="wp-block-query">
<!-- wp:post-template -->
<!-- wp:post-title {"isLink":true} /-->
<!-- wp:post-date /-->
<!-- /wp:post-template -->
</div>
<!-- /wp:query -->
</main>
<!-- /wp:group -->
The comments are not commentary. They carry the block name, the attributes, and where each block starts and stops. Edit that file by hand and the Site Editor will pick up your change, because it is reading the same thing you just wrote.
What the Site Editor edits, and what it will never touch
The Site Editor is the admin surface for a block theme. It opens in the browser at the
site-editor.php screen and gives you a canvas wired to files on disk plus
rows in the database.
| Surface | What it edits | Where changes live |
|---|---|---|
| Templates | The block markup of every template: front page, single post, archive, 404. | User changes go to the database; the theme's file is the fallback. |
| Template parts | Header, footer and any other reusable section, in parts/. |
Database overrides the file, same as templates. |
| Styles | Colour, typography, spacing and layout, plus per-block overrides. | Written as a user global styles record, layered over theme.json. |
| Navigation | Menu structure, stored as a wp_navigation post. |
Database only. There is no file involved. |
| Patterns | Reusable block arrangements from patterns/ and from the user. |
Filesystem for theme patterns, database for user ones. |
Four things stay outside it. PHP is invisible to the Site Editor, so any theme code
beyond markup needs a plugin or a child theme. The same goes for theme.json;
the Styles screen writes a user layer that sits on top of the file, and there is a reset
button for exactly that reason. Plugins, users and site settings live in their own
screens. Nothing in the editor reaches functions.php either, which is a good
thing, because a mistake there
takes the whole site down rather than one template.
Every saved change is an override, not a source edit
That layering surprises people who expect the editor to write files. When you drag the
header padding to a new value, WordPress stores a user
global styles record in the database. On the next request the style engine merges three
layers in order: core defaults, the theme's theme.json, then the user record.
The user value wins, and the CSS prints accordingly.
So updates do not wipe the edits, because the file you replaced never held them.
Switching theme hides them instead, because they were attached to the old theme's slugs.
If a client
reports that their colour change "reverted after the update", the cause is usually a slug
rename in theme.json, which orphans the user layer that referenced the old
name.
The reset control in the Styles screen clears the user layer, which is the fastest way to answer whether a problem is in your file or in their edits. It is not an undo for template edits, so take a copy of a template's markup before you reset anything else.
style.css is a nametag, not a stylesheet
In a classic theme, style.css is where the design lives, and the header
comment doubles as metadata. In a block theme the header still matters and the file
mostly does not. WordPress reads the header to learn the theme's name, version, text
domain and licence, and the theme will not appear correctly in Appearance, Themes without
it, even though no browser ever needs to load it.
/*
Theme Name: WPSifero Base
Theme URI: https://example.test/wpsifero-base
Author: Ana Ferreiro
Description: A minimal block theme used throughout this course.
Requires at least: 7.0
Tested up to: 7.0
Requires PHP: 8.3
Version: 0.1.0
License: GNU General Public License v2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: wpsifero-base
*/
/* The theme's own CSS goes here only when no preset can express it. */
.wpsifero-skip-link {
position: absolute;
left: -9999px;
}
.wpsifero-skip-link:focus {
left: 1rem;
top: 1rem;
}
The theme's own stylesheet is not loaded for you in a block theme, and that is the part
that catches people out. WordPress prints the styles the style engine derives from
theme.json, and it stops there. If you want the rules in
style.css on the front end, you enqueue the file yourself from
functions.php, which is also how the stock block themes do it.
<?php
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_style(
'wpsifero-base',
get_stylesheet_uri(),
array(),
wp_get_theme()->get( 'Version' )
);
} );
Keeping that file near-empty is not about performance. Anything you
write there bypasses the token system. A hard-coded hex value cannot
be changed by a style variation, and the Styles screen will show a colour picker that
does not affect the element you styled by hand. You will meet the exceptions to that rule
in module 5; for now, treat style.css as the place for things
theme.json has no vocabulary for, such as a visually hidden skip link.
Create a new block theme from nothing, by hand, in
wp-content/themes/wpsifero-base/. Do not copy an existing theme and do
not use a generator. Write four files: style.css with a theme header
whose Text Domain matches the directory name and a
.wpsifero-skip-link rule, a functions.php that enqueues
style.css, a templates/index.html containing at least a
heading block with real text, and a theme.json with only a
version key.
Activate it and confirm four things: the theme appears in Appearance, Themes with its name and version, the Site Editor opens and shows your heading on the canvas, the front end prints the same text, and the skip link rule reaches the page. View source and check that your stylesheet is actually linked.
Then rename templates to templates-off and reload the
admin. Record what disappears, what keeps working, and what the front end does. Put
the directory name back before moving on.
Finally, answer in writing: the theme has no header.php and no
footer.php. Why does the front end still produce a valid
<html> document?
Show the solution and the reasoning
The directory holds four files. One is a block theme marker, one is metadata, one registers against WordPress, and one is optional.
wp-content/themes/wpsifero-base/
├── style.css
├── functions.php
├── theme.json
└── templates/
└── index.html
style.css carries the metadata block. Text Domain must
match the directory name if you intend to ship, and Requires PHP: 8.3 is
honest for this course rather than decorative.
/*
Theme Name: WPSifero Base
Version: 0.1.0
Requires at least: 7.0
Requires PHP: 8.3
Text Domain: wpsifero-base
*/
theme.json needs nothing beyond the version. Version 3 is what current
WordPress expects, and a file with a single key is a valid declaration that changes no
defaults.
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3
}
functions.php is what puts the skip link rule on the page. Without this
hook the file loads nothing on the front end, and the rule in
style.css is dead weight.
<?php
add_action( 'wp_enqueue_scripts', function () {
wp_enqueue_style(
'wpsifero-base',
get_stylesheet_uri(),
array(),
wp_get_theme()->get( 'Version' )
);
} );
The template is one block, with its opening and closing comments. Block markup without the delimiters is text, not a block, so the comments are load-bearing.
<!-- wp:heading -->
<h2 class="wp-block-heading">WPSifero Base works.</h2>
<!-- /wp:heading -->
Renaming templates produces a specific and informative failure. The
theme still appears in Appearance, Themes, because the header in style.css
is read from the directory. The Site Editor either disappears from the menu or reports
that the theme is not a block theme. The front end keeps rendering, but it falls back to
the classic template path, so you get whatever WordPress assembles from
index.php if you have one, and a bare index.php in the theme
root that just calls the loop otherwise. The palette still applies if
theme.json is present, which is the detail that makes this confusing: the
visual tokens are theme-type agnostic, and only template handling is not.
The document shell comes from the block template renderer. When WordPress serves a
block template it wraps the rendered blocks in a full document, emitting the doctype,
<head>, wp_head(), the body classes and
wp_footer() itself. There is no header.php to write because
the wrapper is not in the theme at all. A block theme that needs something global in
the head adds it through functions.php with the normal WordPress hooks, and
one that needs extra markup around the whole page writes a
templates/index.html whose outermost block is a group or a cover.
One caveat on the test itself: detection reads the active theme, and parent themes
are checked as well as the stylesheet directory. A child theme with no
templates/index.html still counts as a block theme if its parent has one.
The parent's file satisfies the check, which is how a child theme gets to be nothing
more than a CSS and template override.
Progress
Mark this lesson done and it stays marked in this browser.