WordPress has always offered flexibility in how themes render pages. For years, classic PHP templates ruled the ecosystem. Then Full Site Editing arrived, bringing block templates, template parts, and theme.json into the picture. But you do not have to choose one system or the other. A hybrid theme lets you run both at the same time, giving you a controlled migration path rather than a forced, all-or-nothing switch.
This is Article 2 in the Block Theme Migration Series. If you missed Article 1 on converting a classic WordPress theme to a block theme, it provides the foundation for everything covered here. In this article, you will learn how to structure a hybrid theme, wire up the right add_theme_support calls, combine PHP templates with block template parts, and keep classic widget areas working while you progressively adopt FSE. Every code snippet below uses real WordPress APIs available in WordPress 6.0 and later.
What Is a Hybrid Theme?
A hybrid theme is a classic WordPress theme (one with a functions.php file and PHP templates like index.php, single.php, and archive.php) that also opts into selected block editing features. It does not replace its PHP templates with block templates entirely. Instead, it adds block template part support, registers block template parts for specific regions (header, footer, sidebar), and provides a theme.json file to control editor styles and global settings.
The key distinction is that a hybrid theme still has index.php as its root fallback. WordPress only activates the full block template hierarchy (looking for templates/index.html first) when a theme does not include any PHP templates at all, or when it explicitly declares itself as a block theme via a templates/index.html file and no index.php. Hybrid themes sit between the two worlds on purpose.
Hybrid Theme Architecture and File Structure
Before writing a single line of code, understand what files need to coexist. A well-structured hybrid theme uses this layout:
my-hybrid-theme/
├── style.css
├── functions.php
├── index.php
├── single.php
├── archive.php
├── page.php
├── header.php
├── footer.php
├── sidebar.php
├── theme.json
└── parts/
├── header.html
├── footer.html
└── sidebar.html
Notice the parts/ directory at the root. This is where block template parts live. It is separate from the classic header.php and footer.php files that your existing PHP templates use with get_header() and get_footer(). Both can coexist. Your PHP templates keep calling get_header(); those still load header.php. The block template parts in parts/ are only used when you explicitly render them with block_template_part() or inside a block template file.
The theme.json file at the root is picked up automatically by WordPress regardless of whether you use block templates. It applies global styles, color palettes, typography, and spacing presets to the block editor even when classic PHP templates handle the front end.
Template Parts Directory: parts/ vs templates/parts/
Full block themes store template parts in templates/parts/. Hybrid themes typically use a top-level parts/ directory. Both locations are supported. If you want to keep things consistent with where the ecosystem is heading, use parts/` at the root for now and be ready to move files to `templates/parts/ when you complete the migration. WordPress resolves both locations when looking for registered template parts.
Using add_theme_support for Block Features
The add_theme_support function is how a classic or hybrid theme tells WordPress which block-related features to activate. You add these calls inside your after_setup_theme action in functions.php.
Block Template Parts
add_action( 'after_setup_theme', 'my_hybrid_theme_setup' );
function my_hybrid_theme_setup() {
// Enable block template part areas.
add_theme_support( 'block-template-parts' );
// Load editor styles into the block editor iframe.
add_theme_support( 'editor-styles' );
add_editor_style( 'style.css' );
// Opt into appearance tools in theme.json.
add_theme_support( 'appearance-tools' );
// Keep classic menus working.
register_nav_menus( array(
'primary' => esc_html__( 'Primary Menu', 'my-hybrid-theme' ),
'footer' => esc_html__( 'Footer Menu', 'my-hybrid-theme' ),
) );
// Image sizes and post thumbnails.
add_theme_support( 'post-thumbnails' );
add_theme_support( 'responsive-embeds' );
add_theme_support( 'align-wide' );
}
The block-template-parts support flag is the key one for hybrid themes. It tells WordPress to look for block template parts in your theme’s parts/ directory and to make them editable in Site Editor under Appearance > Template Parts. Without this flag, WordPress ignores the .html files in your parts/ directory entirely.
The editor-styles flag plus add_editor_style() loads your front-end stylesheet into the block editor so the editing experience matches what visitors see. This works whether you use classic or block templates.
The appearance-tools flag unlocks padding, margin, border, link color, and typography controls in theme.json and in individual blocks. It is a shorthand that enables several granular feature flags at once.
What You Should Not Add to a Hybrid Theme
Do not add block-templates support unless you are ready to commit to full block template routing. Once WordPress sees that your theme declares block-templates support and has a templates/index.html file, it starts treating the theme as a block theme and routes requests through the block template hierarchy instead of PHP templates. That is the migration end point, not the starting point.
Combining PHP Templates with Block Template Parts
This is where the practical hybrid approach becomes concrete. You keep your classic PHP templates handling page routing, but you render block template parts inside those PHP templates for regions that benefit from block editing (header, footer, call-to-action sections).
Registering Block Template Parts in functions.php
add_action( 'init', 'my_hybrid_theme_register_template_parts' );
function my_hybrid_theme_register_template_parts() {
register_block_template( 'my-hybrid-theme//header', array(
'title' => __( 'Site Header', 'my-hybrid-theme' ),
'description' => __( 'The global site header.', 'my-hybrid-theme' ),
'area' => 'header',
) );
register_block_template( 'my-hybrid-theme//footer', array(
'title' => __( 'Site Footer', 'my-hybrid-theme' ),
'description' => __( 'The global site footer.', 'my-hybrid-theme' ),
'area' => 'footer',
) );
}
The function register_block_template() was introduced in WordPress 6.7. For WordPress 6.0 to 6.6, you register template parts using the block_template_part taxonomy or by relying on the automatic detection from the parts/ directory when block-template-parts support is active. The newer API gives you explicit control over area, title, and description.
Rendering a Block Template Part Inside a PHP Template
Once registered, you can render a block template part inside any PHP template using the block_template_part() function:
<?php
// header.php, classic PHP template.
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<?php
// Render the block template part instead of a PHP partial.
block_template_part( 'header' );
?>
The block_template_part() function looks for parts/header.html in your theme, parses the block markup inside it, and renders the output. The content of that file is fully editable in Site Editor under Appearance > Template Parts, meaning non-developers can update the header without touching PHP files. For a deeper look at how template parts work in full block themes, see our guide on building block theme template parts for modular site architecture.
Your footer.php follows the same pattern:
<?php
// footer.php, classic PHP template.
block_template_part( 'footer' );
wp_footer();
?>
</body>
</html>
A Minimal parts/header.html File
<!-- wp:group {"tagName":"header","className":"site-header","layout":{"type":"constrained"}} -->
<header class="wp-block-group site-header">
<!-- wp:site-logo /-->
<!-- wp:navigation {"ref":1,"layout":{"type":"flex","justifyContent":"right"}} /-->
</header>
<!-- /wp:group -->
This is valid block markup. It uses the Site Logo block and Navigation block. Editors can open Site Editor, click on Template Parts, find the Site Header, and modify it with the visual editor. The change persists in the database and overrides the file on the next render.
Progressive Adoption Strategy: Which Pages to Migrate First
Migrating every template at once is risky and unnecessary. A phased approach reduces regression risk and lets you validate each converted template before moving on.
Phase 1: Static Pages (Lowest Risk)
Start with pages that have the simplest markup and no dynamic PHP logic. A typical About page or Contact page has a title, some content blocks, and maybe a form. Convert page.php to a block template first:
- Create
templates/page.htmlwith the block equivalent of your PHP layout. - Move
page.phptopage.php.bakso PHP routing no longer picks it up. - WordPress now uses
templates/page.htmlfor all pages. - Verify the output matches the original on multiple pages.
- If something breaks, restore
page.phpimmediately.
Phase 2: Single Post Templates
Single post templates are the next step. The block equivalent of a classic single.php uses the Post Content block, Post Title block, and Post Featured Image block. Convert one post type at a time. If your theme supports custom post types, start with the default post type before touching CPTs.
<!-- templates/single.html -->
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-featured-image /-->
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-date /-->
<!-- wp:post-content /-->
<!-- wp:post-comments-form /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
Phase 3: Archive and Search Templates
Archive templates involve loops. The Query Loop block replaces the classic WordPress loop. This is where the migration gets more involved, especially if you have customized the query with pre_get_posts hooks or custom WP_Query calls. Keep PHP archive templates until you can replicate the exact query behavior with Query Loop block attributes or a custom block variation.
Phase 4: Header and Footer (Highest Visibility)
Ironically, the header and footer are often migrated via block template parts early (as shown above with block_template_part()) without fully converting to block templates. That gives you the editor flexibility for these high-value regions without requiring a full template migration. Complete the template migration last, once every other template has been converted and tested.
Maintaining Classic Widget Areas Alongside Block Widget Areas
Widget areas present one of the trickiest aspects of hybrid theme development. WordPress 5.8 moved Customizer widgets to the block-based Widgets screen. If your classic theme has sidebar widgets, they still work. But if you also want block-editable widget areas, you need to be deliberate about coexistence.
Keeping Classic Sidebars Registered
add_action( 'widgets_init', 'my_hybrid_theme_register_sidebars' );
function my_hybrid_theme_register_sidebars() {
register_sidebar( array(
'name' => __( 'Main Sidebar', 'my-hybrid-theme' ),
'id' => 'sidebar-1',
'description' => __( 'Add widgets here.', 'my-hybrid-theme' ),
'before_widget' => '<section id="%1$s" class="widget %2$s">',
'after_widget' => '</section>',
'before_title' => '<h2 class="widget-title">',
'after_title' => '</h2>',
) );
}
As long as you keep register_sidebar() calls in your theme, the classic widget areas remain available in Appearance > Widgets and via dynamic_sidebar() in your PHP templates. These are separate from block template parts.
Block Widget Areas in Template Parts
For regions you want to make block-editable (not tied to classic widget areas), use a dedicated block template part instead of a sidebar. Create parts/sidebar.html and call it from your PHP sidebar template when the classic widget area is empty, or as a separate UI element alongside the classic sidebar.
<?php
// sidebar.php
if ( is_active_sidebar( 'sidebar-1' ) ) {
dynamic_sidebar( 'sidebar-1' );
} else {
// Fall back to block template part when no widgets are assigned.
block_template_part( 'sidebar' );
}
?>
This pattern gives you a clean migration path. While your team is still adding widgets in the classic Widgets screen, the classic sidebar renders. Once they move to the block template part editor, the fallback kicks in. No PHP changes required.
Disabling Block Widgets Without Removing Classic Sidebars
If you want to keep the old Customizer-based widget UI (not the block Widgets screen) during the transition, add this to your theme setup:
add_filter( 'use_widgets_block_editor', '__return_false' );
This restores the pre-5.8 widget experience. Use it temporarily during a migration to avoid confusing content editors while the hybrid theme is partially converted. Remove it once you have fully migrated widget areas to block template parts.
theme.json Configuration for Hybrid Themes
A well-configured theme.json file does two things for a hybrid theme: it controls the block editor experience for all content (even classic PHP template sites use blocks in the post editor), and it sets up global styles that apply to both the editor and the front end.
The key is to configure theme.json without breaking classic template compatibility. This means being careful about spacing scale and typography settings that could affect existing CSS.
A Hybrid-Safe theme.json Starting Point
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"appearanceTools": true,
"color": {
"palette": [
{
"slug": "primary",
"color": "#1a56db",
"name": "Primary"
},
{
"slug": "secondary",
"color": "#111827",
"name": "Secondary"
},
{
"slug": "background",
"color": "#ffffff",
"name": "Background"
},
{
"slug": "surface",
"color": "#f9fafb",
"name": "Surface"
}
],
"gradients": [],
"defaultPalette": false,
"defaultGradients": false
},
"typography": {
"fontFamilies": [
{
"slug": "system",
"name": "System",
"fontFamily": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif"
}
],
"fontSizes": [
{ "slug": "sm", "size": "0.875rem", "name": "Small" },
{ "slug": "base", "size": "1rem", "name": "Base" },
{ "slug": "lg", "size": "1.125rem", "name": "Large" },
{ "slug": "xl", "size": "1.25rem", "name": "XL" },
{ "slug": "2xl", "size": "1.5rem", "name": "2XL" },
{ "slug": "3xl", "size": "1.875rem", "name": "3XL" },
{ "slug": "4xl", "size": "2.25rem", "name": "4XL" }
],
"defaultFontSizes": false,
"fluid": true
},
"spacing": {
"spacingScale": {
"operator": "*",
"increment": 1.5,
"steps": 7,
"mediumStep": 1.5,
"unit": "rem"
},
"defaultSpacingSize": false,
"spacingSizes": []
},
"layout": {
"contentSize": "720px",
"wideSize": "1200px"
},
"useRootPaddingAwareAlignments": true,
"blocks": {}
},
"styles": {
"color": {
"background": "var(--wp--preset--color--background)",
"text": "var(--wp--preset--color--secondary)"
},
"typography": {
"fontFamily": "var(--wp--preset--font-family--system)",
"fontSize": "var(--wp--preset--font-size--base)",
"lineHeight": "1.7"
},
"elements": {
"link": {
"color": {
"text": "var(--wp--preset--color--primary)"
},
":hover": {
"color": {
"text": "var(--wp--preset--color--secondary)"
}
}
},
"h1": { "typography": { "fontSize": "var(--wp--preset--font-size--4xl)", "fontWeight": "700" } },
"h2": { "typography": { "fontSize": "var(--wp--preset--font-size--3xl)", "fontWeight": "700" } },
"h3": { "typography": { "fontSize": "var(--wp--preset--font-size--2xl)", "fontWeight": "600" } },
"h4": { "typography": { "fontSize": "var(--wp--preset--font-size--xl)", "fontWeight": "600" } }
},
"blocks": {}
},
"templateParts": [
{
"name": "header",
"title": "Header",
"area": "header"
},
{
"name": "footer",
"title": "Footer",
"area": "footer"
},
{
"name": "sidebar",
"title": "Sidebar",
"area": "uncategorized"
}
]
}
Key Decisions in This theme.json
defaultPalette: false and defaultFontSizes: false – These two settings remove WordPress core’s default color palette and font size presets from the editor. In a classic theme, these defaults often conflict with existing CSS custom properties or theme-defined classes. Disabling them gives you a clean slate and forces the editor to use only the values you define.
useRootPaddingAwareAlignments: true – This enables the CSS variables --wp--style--root--padding-* and allows full-width blocks to break out of the content container properly. It is safe to enable in hybrid themes as it only affects block-rendered output.
templateParts array – Declaring template parts in theme.json registers them with the Site Editor automatically. This is the declarative alternative to the register_block_template() PHP call shown earlier. Use one or the other, not both, to avoid duplicate registrations.
fluid: true in typography – Enables fluid typography via CSS clamp() for all registered font sizes. WordPress generates the CSS custom properties with fluid values automatically. Your existing PHP templates benefit from this too, as long as their markup includes block editor output (the post content area).
Avoiding theme.json Settings That Break Classic Templates
Two settings to avoid in a hybrid theme’s theme.json until you are ready for full FSE:
- Do not set body background or text colors in styles.color at the global level if your classic templates use a completely different CSS reset or normalize approach. The global styles output a
body { background-color: ...; color: ...; }rule that may override your carefully crafted front-end CSS. Test this carefully. - Do not enable customTemplates in theme.json with PHP templates still in place. Custom templates defined in
theme.jsonare expected to have corresponding.htmlfiles intemplates/. Declaring one without the file causes a 404 or fallback to the index template.
How WordPress Decides Which Template System to Use
Understanding the template resolution order is important when you are running both systems. WordPress does not randomly pick between block and PHP templates. The logic is deterministic.
The Block Template Hierarchy Check
For each request, WordPress first determines what kind of content is being shown (single post, archive, search, 404, etc.). It then checks for block templates in this order:
- Template saved to the database by the user via Site Editor (highest priority)
- Template file in the active theme’s
templates/directory - Template file in the parent theme’s
templates/directory (for child themes) - Template from a plugin registered via
register_block_template()
If any block template is found, WordPress uses it and skips PHP template resolution entirely for that request.
The PHP Template Hierarchy
If no block template is found, WordPress falls through to the classic PHP template hierarchy. For a single post, it checks in this order:
single-{post-type}-{post-slug}.phpsingle-{post-type}.phpsingle.phpsingular.phpindex.php
This means in a hybrid theme, if you create templates/single.html, all single posts will use the block template. If you only create templates/single-product.html, only the WooCommerce product post type uses the block template. All other single posts still fall through to single.php. This surgical control is exactly what makes the progressive migration strategy work.
Checking Which System Is Active Programmatically
// Check if the current theme is a block theme.
if ( wp_is_block_theme() ) {
// Full FSE mode. Templates are .html files.
} else {
// Classic or hybrid mode. PHP templates still in control.
}
// Check if a specific block template exists for the current request.
$templates = resolve_block_template( get_queried_object_type(), array(), '' );
if ( $templates ) {
// A block template was found and will be used.
}
The wp_is_block_theme() function returns true only when the theme has templates/index.html and no index.php. In a properly configured hybrid theme, it returns false, confirming PHP routing remains in control.
Practical Checklist Before Going Live with a Hybrid Theme
Use this list as a pre-launch sanity check after building out your hybrid theme:
- index.php exists at theme root – Confirms the theme is not accidentally treated as a block theme.
- block-template-parts support is declared – Required for
block_template_part()to work and for template parts to appear in Site Editor. - theme.json version is 3 – Version 3 is current as of WordPress 6.6. Earlier versions miss newer features and type safety.
- Template parts registered in theme.json or via register_block_template() – Not both. Duplicate registrations cause unexpected behavior in Site Editor.
- Classic sidebars still registered – Confirm
widgets_inithook still fires and dynamic_sidebar() returns true for active sidebars. - No block templates for pages you have not migrated – A stray
templates/archive.htmlfile will override yourarchive.phpsilently. - Editor styles match front end – Load your stylesheet via
add_editor_style()and verify in the block editor that headings and paragraph spacing look correct. - theme.json custom properties match your CSS – If your front-end CSS uses
var(--wp--preset--color--primary), verify the slug in theme.json matches exactly.
Common Pitfalls and How to Avoid Them
Pitfall: Accidentally Creating a Block Theme
Adding templates/index.html to a theme that still has index.php does not turn it into a block theme. WordPress checks for the absence of index.php as part of the block theme detection logic. But if you rename or delete index.php, the site switches to block template routing on the next request. Keep index.php until you are fully ready.
Pitfall: Block Template Parts Not Appearing in Site Editor
If you create parts/header.html but do not see it in Appearance > Template Parts, the most likely cause is that add_theme_support( 'block-template-parts' ) is missing from your theme setup function. The second most likely cause is that the file extension or directory name is wrong. Confirm the file is at parts/header.html relative to the theme root, with the exact name matching what you pass to block_template_part().
Pitfall: theme.json Global Styles Conflicting with Classic CSS
When you add a theme.json file to a classic theme, WordPress begins outputting CSS custom properties and potentially global style rules in the <head>. If your existing stylesheet uses the same property names or has high-specificity rules that the theme.json output overrides, you will see visual regressions. Audit your existing CSS for any rules targeting body, :root, or block class names before adding theme.json.
Pitfall: Block Template Parts Not Updating After File Changes
Once a user saves a block template part via Site Editor, the database version takes precedence over the file. If you update parts/header.html in the theme files, users with customized versions will not see your change. To push file-level updates to everyone, you need to delete the database-saved version via Site Editor (Revert to Theme or use a WP-CLI command: wp post delete $(wp post list --post_type=wp_template_part --name=header --field=ID)) before the file update takes effect.
Moving Forward: When to Complete the Migration
A hybrid theme is a bridge, not a destination. The right time to complete the migration to a full block theme is when:
- All PHP templates have been converted to
.htmlblock templates and tested. - All classic widget areas have been replaced by block template parts or core blocks (Navigation, Site Logo, Search).
- All custom PHP queries in archive templates have been replicated with Query Loop block variations or the Interactivity API.
- Your team is comfortable editing templates in Site Editor rather than PHP files.
At that point, delete index.php from the theme root, rename the parts/ directory to templates/parts/ (or verify WordPress finds them at the current location), and declare block-templates support. WordPress will recognize the theme as a full block theme, and Site Editor will unlock the full template editing experience including the ability to create new templates visually.
Article 3 in this series will cover converting a classic navigation menu to the Navigation block, including how to migrate menu items, handle mega menus, and use the Navigation block’s Interactivity API integration for accessible dropdowns. If you are building the hybrid header shown in this article, that article is the next natural step.
Building a hybrid theme is not a shortcut around learning FSE. It is a structured way to get there without breaking a live site. The architecture decisions you make now (which template parts to expose, how you configure theme.json, where you draw the PHP/block boundary) will directly determine how smooth the final migration is. Take each phase deliberately, test at every step, and the block editing transition becomes a feature your team ships incrementally rather than a rewrite that lands all at once.
