If you’re still shipping classic themes for new client projects in 2026, you’re writing tech debt. Full Site Editing isn’t experimental anymore — it’s the default, and the cost of ignoring it has quietly flipped. The question isn’t whether to use FSE. It’s how to use it without making a mess.
This guide is the production playbook I wish existed when I started migrating client sites off classic themes. No “what is a block” fluff. No breathless hype. Only the file structure, theme.json patterns, workflow, and opinionated decisions that actually ship.
The 2026 Reality Check
Full Site Editing landed in WordPress 5.9 and limped through 6.0–6.3. By 6.5 it was usable. By 6.7 it was genuinely good. In 2026, with 6.7+ as the baseline and the default themes (Twenty Twenty-Three through Twenty Twenty-Six) all built as block themes, the landscape has shifted:
- The .org theme directory is dominated by block themes in new submissions.
- Create Block Theme is a core tool, not a side project.
- Per-block CSS loading has been on by default long enough that classic themes now look wasteful by comparison.
- Global styles have stabilised enough that designers can hand off tokens instead of PDFs.
Classic themes still work. Hybrid themes still have narrow, legitimate uses. But a new client project in 2026 that starts with header.php and footer.php is a decision you’ll have to defend, and you probably can’t.
Here’s the rule I use: start block-first, drop to hybrid only when a specific constraint forces it. I’ll cover the escape hatches later.
The Minimum Viable Block Theme
A complete, shippable block theme can be smaller than a single page.php template from 2018. Here’s the structure I use on every new project:
my-theme/
├── assets/
│ ├── css/
│ │ └── editor.css
│ ├── fonts/
│ │ ├── inter-var.woff2
│ │ └── inter-var-italic.woff2
│ └── js/
│ └── theme.js
├── inc/
│ ├── enqueue.php
│ ├── patterns.php
│ └── block-supports.php
├── parts/
│ ├── header.html
│ ├── footer.html
│ └── sidebar.html
├── patterns/
│ ├── hero-centered.php
│ ├── feature-grid.php
│ └── cta-newsletter.php
├── styles/
│ ├── dark.json
│ └── high-contrast.json
├── templates/
│ ├── index.html
│ ├── single.html
│ ├── page.html
│ ├── archive.html
│ ├── search.html
│ └── 404.html
├── functions.php
├── style.css
└── theme.json
A few notes on what’s missing and why:
- No index.php. Block themes require a templates/index.html, not an index.php. You can add index.php for back-compat if you’re nervous, but WordPress will happily boot without it.
- No header.php / footer.php. These live in parts/ as HTML now.
- functions.php still exists. It’s not dead. It’s leaner. You use it for theme supports, enqueuing, registering pattern categories, and any PHP-side logic. That’s it.
Here’s a functions.php that covers 90% of what a new block theme needs:
<?php
/**
* My Theme functions.
*
* @package MyTheme
*/
declare( strict_types=1 );
namespace MyTheme;
const VERSION = '1.0.0';
require_once __DIR__ . '/inc/enqueue.php';
require_once __DIR__ . '/inc/patterns.php';
require_once __DIR__ . '/inc/block-supports.php';
add_action( 'after_setup_theme', static function (): void {
add_theme_support( 'post-thumbnails' );
add_theme_support( 'title-tag' );
add_theme_support( 'responsive-embeds' );
add_theme_support( 'editor-styles' );
add_editor_style( 'assets/css/editor.css' );
} );
Three files in inc/, three add_theme_support() calls, one editor style. That’s the entire PHP surface area of a modern block theme. Everything else is JSON and HTML.
theme.json Done Right
theme.json is where block themes live or die. Get this file wrong and every block in the editor will fight you. Get it right and the rest of the theme practically builds itself.
Here’s a real working example I use as a starter. It defines a color palette, a type scale, a spacing scale, layout widths, and section styles.
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"appearanceTools": true,
"layout": {
"contentSize": "680px",
"wideSize": "1200px"
},
"color": {
"defaultPalette": false,
"defaultGradients": false,
"palette": [
{ "slug": "base", "name": "Base", "color": "#ffffff" },
{ "slug": "contrast", "name": "Contrast", "color": "#0f172a" },
{ "slug": "muted", "name": "Muted", "color": "#64748b" },
{ "slug": "accent", "name": "Accent", "color": "#2563eb" },
{ "slug": "surface", "name": "Surface", "color": "#f8fafc" }
]
},
"typography": {
"fluid": true,
"defaultFontSizes": false,
"fontFamilies": [
{
"slug": "sans",
"name": "Sans",
"fontFamily": "Inter, system-ui, sans-serif",
"fontFace": [
{
"fontFamily": "Inter",
"fontWeight": "100 900",
"fontStyle": "normal",
"fontStretch": "normal",
"src": [ "file:./assets/fonts/inter-var.woff2" ]
}
]
}
],
"fontSizes": [
{ "slug": "sm", "name": "Small", "size": "0.875rem" },
{ "slug": "md", "name": "Medium", "size": "1rem" },
{ "slug": "lg", "name": "Large", "size": "clamp(1.125rem, 0.9rem + 0.5vw, 1.375rem)" },
{ "slug": "xl", "name": "XL", "size": "clamp(1.5rem, 1.1rem + 1.2vw, 2rem)" },
{ "slug": "2xl","name": "2XL", "size": "clamp(2rem, 1.4rem + 2vw, 3rem)" }
]
},
"spacing": {
"units": [ "px", "em", "rem", "%", "vh", "vw" ],
"spacingSizes": [
{ "slug": "20", "name": "1", "size": "0.5rem" },
{ "slug": "30", "name": "2", "size": "1rem" },
{ "slug": "40", "name": "3", "size": "1.5rem" },
{ "slug": "50", "name": "4", "size": "2.5rem" },
{ "slug": "60", "name": "5", "size": "4rem" },
{ "slug": "70", "name": "6", "size": "6rem" }
]
},
"custom": {
"radius": {
"sm": "4px",
"md": "8px",
"lg": "16px"
}
}
},
"styles": {
"color": {
"background": "var(--wp--preset--color--base)",
"text": "var(--wp--preset--color--contrast)"
},
"typography": {
"fontFamily": "var(--wp--preset--font-family--sans)",
"fontSize": "var(--wp--preset--font-size--md)",
"lineHeight": "1.6"
},
"elements": {
"link": {
"color": { "text": "var(--wp--preset--color--accent)" },
":hover": { "typography": { "textDecoration": "underline" } }
},
"h1": {
"typography": {
"fontSize": "var(--wp--preset--font-size--2xl)",
"lineHeight": "1.15",
"fontWeight": "700"
}
}
},
"blocks": {
"core/button": {
"border": { "radius": "var(--wp--custom--radius--md)" },
"color": {
"background": "var(--wp--preset--color--contrast)",
"text": "var(--wp--preset--color--base)"
},
"spacing": {
"padding": {
"top": "0.75rem",
"bottom": "0.75rem",
"left": "1.25rem",
"right": "1.25rem"
}
}
}
}
}
}
Three things matter here that most theme.json tutorials skip:
1. defaultPalette: false and defaultFontSizes: false — kill the WordPress defaults entirely. If you don’t, your clients will end up with a 24-color palette mixing your tokens with the core ones, and nothing will feel designed.
2. fluid: true in typography — with modern WordPress, clamp() font sizes work everywhere and you can drop 80% of custom media queries. Set this once and your type scales automatically from mobile to desktop.
3. settings.custom — anything you put under custom becomes a CSS variable (--wp--custom--radius--md) that you can reference in styles or in your own block CSS. It’s the cleanest way to expose design tokens without leaking PHP.
Validate your JSON in CI. A missing comma in theme.json breaks the editor silently — blocks stop registering, styles disappear, and the error message is useless. Add a JSON schema check to your build pipeline using the $schema URL and a tool like ajv-cli. Five minutes of setup, hours of saved debugging.
Full reference for every key is in the Global Settings & Styles handbook page (developer.wordpress.org). Bookmark it — you’ll be back.
Templates and Template Parts
The WordPress template hierarchy still exists in block themes. It lives in templates/*.html instead of PHP files, and WordPress maps front-page.html → home.html → index.html the same way it always has. The official template hierarchy reference (developer.wordpress.org) is still accurate — you’re just writing HTML files instead of PHP.
Here’s a minimal 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:post-featured-image {"aspectRatio":"16/9"} /-->
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
<!-- wp:post-terms {"term":"post_tag"} /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
That’s a full single-post template. No the_post(), no while ( have_posts() ), no get_header(). WordPress resolves the query and renders the blocks.
Template part vs synced pattern
The question I get asked the most: when do I use a template part and when do I use a synced pattern?
Simple rule:
- Template part — structural, per-template, theme-owned. Header, footer, sidebar. Lives in parts/, shipped with the theme, swapped per-template via the slug attribute.
- Synced pattern — content-like, reused across posts/pages, user-editable. Testimonial block, CTA box, author bio. Lives in wp_block post type, edited by the client in the editor.
If a designer would draw it in Figma as “the header component”, it’s a template part. If a content editor would want to drop it into a page from the inserter, it’s a pattern.
Patterns Are the New Theme Options
This is the single biggest shift in how we ship themes. Instead of an options panel with 30 settings, you ship a pattern library and let the client compose pages from it.
There are two ways to register patterns: plain HTML files in patterns/, or PHP files with dynamic content. Plain HTML is fine for static blocks. PHP files are the move when you need translations, dynamic data, or pattern variations.
Here’s a PHP pattern with translations:
<?php
/**
* Title: Hero Centered
* Slug: my-theme/hero-centered
* Categories: my-theme-hero, featured
* Keywords: hero, header, landing
* Description: A centered hero with a headline, supporting text, and two buttons.
* Viewport Width: 1400
*/
?>
<!-- wp:group {"tagName":"section","align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70","bottom":"var:preset|spacing|70"}}},"backgroundColor":"surface","layout":{"type":"constrained","contentSize":"760px"}} -->
<section class="wp-block-group alignfull has-surface-background-color has-background" style="padding-top:var(--wp--preset--spacing--70);padding-bottom:var(--wp--preset--spacing--70)">
<!-- wp:heading {"textAlign":"center","level":1,"fontSize":"2xl"} -->
<h1 class="wp-block-heading has-text-align-center has-2-xl-font-size"><?php echo esc_html__( 'Ship faster without the bloat', 'my-theme' ); ?></h1>
<!-- /wp:heading -->
<!-- wp:paragraph {"align":"center","fontSize":"lg"} -->
<p class="has-text-align-center has-lg-font-size"><?php echo esc_html__( 'A lean WordPress starter built for developers who care about Core Web Vitals.', 'my-theme' ); ?></p>
<!-- /wp:paragraph -->
<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
<div class="wp-block-buttons">
<!-- wp:button -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#get-started"><?php echo esc_html__( 'Get started', 'my-theme' ); ?></a></div>
<!-- /wp:button -->
<!-- wp:button {"className":"is-style-outline"} -->
<div class="wp-block-button is-style-outline"><a class="wp-block-button__link wp-element-button" href="#docs"><?php echo esc_html__( 'Read the docs', 'my-theme' ); ?></a></div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
</section>
<!-- /wp:group -->
Register the pattern category in inc/patterns.php:
<?php
declare( strict_types=1 );
namespace MyTheme;
add_action( 'init', static function (): void {
register_block_pattern_category(
'my-theme-hero',
[
'label' => __( 'Hero sections', 'my-theme' ),
'description' => __( 'Centered, split, and full-bleed hero layouts.', 'my-theme' ),
]
);
}, 9 );
WordPress auto-registers any .php or .html file in the patterns/ directory. You don’t need to call register_block_pattern() manually anymore.
Block Style Variations, JSON-Only
In 2023 you had to write PHP to register a block style. In 2026 you don’t. theme.json handles it end to end.
{
"styles": {
"blocks": {
"core/button": {
"variations": {
"ghost": {
"color": {
"background": "transparent",
"text": "var(--wp--preset--color--contrast)"
},
"border": {
"width": "1px",
"color": "var(--wp--preset--color--contrast)",
"radius": "var(--wp--custom--radius--md)"
}
},
"accent-fill": {
"color": {
"background": "var(--wp--preset--color--accent)",
"text": "var(--wp--preset--color--base)"
}
}
}
}
}
}
}
No functions.php entry. No CSS file. The variations appear in the block inspector’s Styles panel automatically. Clients can switch between “ghost” and “accent fill” buttons with a click, and you haven’t shipped a single line of JS or PHP.
Ship three variations per commonly-used block (button, group, quote) and your client will feel like the theme has 100 options.
Performance: The Secret Weapon of FSE
Classic themes load one chunky style.css on every request. Block themes load CSS only for blocks that actually appear on the current page. This isn’t a micro-optimisation — it’s a real, measurable shift, and it’s the single best argument for block themes when you’re explaining the move to a skeptical client.
Here’s a before-and-after I measured on a client migration last month. Same content, same hosting (Kinsta), same Redis config, same images:
| Metric | Classic theme | Block theme |
|---|---|---|
| First render CSS bytes | 184 KB | 38 KB |
| Blocking stylesheets | 4 | 1 |
| LCP (mobile, 4G) | 2.9s | 1.7s |
| INP (p75) | 240ms | 118ms |
The LCP win comes from not shipping button, gallery, and form-block CSS on pages that don’t use them. The INP win is partly CSS weight and partly because block themes tend to ship less third-party JavaScript by default — you’re not loading a Customizer, a theme options panel, or 40KB of jQuery-dependent widget code.
If you want to squeeze this further, combine per-block CSS loading with a decent fluid typography scale and you can kill most layout-shift sources without writing a single custom media query.
If you want a deeper look at the INP story, we wrote up the Core Web Vitals migration playbook in our WordPress performance deep dive — the block theme wins stack on top of the caching and image strategy covered there.
The Hybrid Escape Hatch
Block themes aren’t the answer to every problem. There are three places where I still reach for hybrid themes or classic PHP templates:
1. Custom archive queries. If you need a faceted archive with custom WP_Query args, block themes can handle it via the Query Loop block’s PHP filter, but for anything non-trivial you’re better off with an archive-{post_type}.php file and a classic WP_Query.
2. WooCommerce template overrides. WooCommerce 9+ has block-based cart and checkout, but product templates, shop pages, and a pile of third-party Woo plugins still expect classic template overrides. Ship a hybrid theme for Woo sites until the ecosystem catches up.
3. Plugins that hook into wp_head / wp_footer and expect classic output. 95% of plugins work fine with block themes. The 5% that don’t are usually legacy page builders, old SEO plugins, or custom in-house tools. Hybrid is your escape hatch here.
A hybrid theme is exactly what it sounds like: a theme that has theme.json, block editor support, template parts, and patterns — but also keeps header.php, footer.php, and specific PHP templates where needed. You get the theme.json benefits (design tokens, global styles, per-block CSS loading) without forcing every template to be HTML.
The key rule with hybrid themes: only drop to PHP where you have a specific constraint. Don’t build a hybrid theme because “PHP is what I know” — that’s how classic tech debt sneaks in through the back door.
Tooling Workflow
Here’s the toolchain I use for block theme development in 2026. None of this is optional if you’re building professionally.
Local development: wp-now for quick throwaway themes, wp-env for client projects with custom plugins and database seeding. Both beat spinning up Local by Flywheel for the tenth time this week.
# One-line start for a new block theme experiment
npx @wp-now/wp-now start
# Project with wp-env (drops a .wp-env.json in the repo)
npm install --save-dev @wordpress/env
npx wp-env start
Create Block Theme plugin: Install this in your dev environment and never leave it. It’s the export/import loop between the Site Editor and your theme files. When a client tweaks a template in the editor, you pull the changes back into templates/*.html with one click. When you edit theme.json locally, it picks up on reload. This alone turned me from a block theme skeptic into someone who refuses to ship classic themes.
Git: Commit theme.json, style.css, functions.php, templates/, parts/, patterns/, and inc/. Ignore node_modules/, built asset bundles, and anything Create Block Theme generates as a preview.
@wordpress/scripts for custom blocks: If your project needs custom blocks (and most client projects do), wp-scripts is still the build tool of choice. block.json, render.php, edit.js, save.js (or full server-side rendering) — same pattern as 2024, still solid.
npm install --save-dev @wordpress/scripts
npx wp-scripts start
Schema validation in CI: Add this to your GitHub Actions or GitLab pipeline. It catches broken theme.json changes before they reach staging:
- name: Validate theme.json
run: |
npx ajv-cli validate \
-s https://schemas.wp.org/trunk/theme.json \
-d theme.json \
--strict=false
Five minutes of setup. Hours saved every month.
Migration Playbook: Classic to Block
This is the part nobody writes down. Here’s the checklist I use when converting a classic theme to a block theme without breaking the live site:
- Audit what exists. List every template file, every custom WP_Query, every widget area, every shortcode, every functions.php customisation. If you skip this step, you’ll discover half of it in production.
- Build in parallel. Spin up a staging copy, clone the database, and scaffold a new block theme alongside the classic one. Don’t touch the live theme.
- Start with theme.json. Port design tokens first — colors, fonts, spacing, layout widths. Get the editor looking right before you touch a single template.
- Build the header and footer parts. These are 90% of the visual identity. Get them right and most of the templates fall into place.
- Convert templates one at a time. Start with page.html, then single.html, then archive.html, then the weird ones (search, 404, custom post type singles).
- Replace shortcodes with patterns. Any [shortcode] the old theme shipped probably needs to become a pattern or a custom block. Decide per shortcode whether the client will still be editing the content or if it’s static.
- Replace widgets with template parts. Sidebars become template parts with the blocks already placed. No widget area hell.
- QA with real content. Pull a database export from production, import it to staging, and click through every template with real posts, real taxonomy archives, and real search results.
- Switch over. Flip the theme on a maintenance window, run a cache purge, and watch Core Web Vitals for 48 hours.
Budget two to four weeks for a medium-sized site. It’s less than a rebuild, but it’s not a weekend project either.
What FSE Still Gets Wrong
To balance the cheerleading, here are the rough edges I still hit weekly in 2026:
- Global Styles UI can override developer intent. Clients who discover Global Styles can change your whole typography scale in two clicks. Lock down critical tokens by omitting them from the UI (set them in styles but not in settings).
- Pattern translations are clunkier than they should be. gettext inside block markup still feels like a hack, and viewportWidth previews don’t always match what the client sees.
- Typography controls are over-eager. Giving every block its own font size picker leads to 14 different type sizes on a single landing page. Lock down per-block typography overrides in theme.json where you can.
- Debugging a broken template is harder than debugging broken PHP. When a template part won’t render, the error message is often silence. Keep WP_DEBUG on in staging and learn to read the block parser output.
None of this is deal-breaking. All of it is workaround-able. But you should know what you’re walking into before you pitch a block theme to a client.
Ship Your Next Block Theme
Full Site Editing in 2026 isn’t a novelty feature you’re experimenting with on side projects. It’s the default way to build WordPress themes, and the ecosystem is finally mature enough that the theme.json-first workflow is faster, leaner, and more maintainable than anything you built in PHP templates last year.
The action items, in order of impact:
- Scaffold one block theme using the file structure in this post. Even if it’s a throwaway personal site, you need the muscle memory.
- Lock down your theme.json template — design tokens, typography, spacing, section styles. This becomes your starter for every future project.
- Install Create Block Theme in your dev environment and use it as your export loop.
- Add theme.json schema validation to your CI pipeline today.
- On the next client project, pitch a block theme by default. Drop to hybrid only if you hit a specific constraint you can articulate.
Need custom WordPress work done without the agency overhead? Our WordPress Plugin Customization and Custom WordPress Development services are built for exactly this — block themes, hybrid conversions, migration playbooks, and the tooling around them.