Coding Classic Themes with Block Patterns in WordPress: A Pragmatic Developer’s Guide

Every few months, the WordPress core team publishes another enthusiastic announcement about Full Site Editing. The official message has not changed since 2021: block themes are the future, PHP templates are a legacy burden, and everyone should build websites out of JSON files and HTML block comments.

If you build WordPress sites for paying clients, enterprise teams, or editorial newsrooms, you know how detached that message is from production reality.

Full Site Editing (FSE) promises a no-code paradise where anyone can build complex websites by clicking buttons in a browser. In practice, handing a non-technical client full control of an FSE block theme is like handing a toddler a permanent marker and walking out of the room. Within forty-eight hours, an editor deletes the global navigation block from the header, inserts three unconstrained full-width images into a narrow sidebar, drops bright purple text on an orange background, and opens a high-priority support ticket asking why the site looks broken on an iPhone.

Even when clients do not break things, the output generated by the block editor’s site-wide rendering engine is messy. You get deep DOM trees packed with redundant container divs, inline CSS dumped directly into markup, and random markup validation errors that trigger the dreaded “This block contains unexpected or invalid content” prompt.

You do not have to buy into the FSE hype to use modern WordPress. Nor do you have to stay frozen in 2014 with ancient page builders or rigid widget areas.

There is a pragmatic middle ground that professional developers have adopted: the hybrid classic theme. By combining the rock-solid predictability of the standard PHP template hierarchy with Gutenberg Block Patterns and a restrictive theme.json configuration, you get complete control over your markup, CSS, and document structure, while still giving content editors a smooth, modular editing experience.

Here is how to set it up, keep your code clean, and protect your layouts from accidental client destruction.

Why Full Site Editing creates production headaches

Before looking at code, let us be clear about what Full Site Editing actually does and why it causes friction on custom client projects.

1. Inlined CSS and specificity conflicts

Block themes rely heavily on WordPress’s internal style engine. Instead of scoping design decisions into a clean external stylesheet, the block editor injects inline CSS directly onto elements:

<div class="wp-block-group" style="padding-top:var(--wp--preset--spacing--50);padding-bottom:var(--wp--preset--spacing--50);margin-top:0px">
  <h2 style="font-size:clamp(1.75rem, 3vw, 2.5rem);line-height:1.2">Our Mission</h2>
</div>

Inline styles override standard CSS rules. When you want to refine global typography, adjust mobile breakpoints, or refactor a site-wide grid system, you find yourself wrestling with inline declarations. You either end up slapping !important flags all over your custom CSS or writing complicated selectors to fight WordPress’s core presets. That is bad frontend architecture.

2. Markup bloat and loss of semantic control

In an FSE block theme, every single element — headers, navigation menus, footers, query loops — is parsed from HTML comments. To create a three-column card row, Gutenberg frequently nests multiple layers of wrappers:

  • wp-block-group
  • wp-block-group__inner-container
  • wp-block-columns
  • wp-block-column

Inspecting the DOM of an FSE site often reveals twelve levels of nested divs before you reach a single heading or paragraph. Semantic landmark elements like <main><header>, and <nav> get buried or misapplied depending on how the editor clicked their way through the block hierarchy.

3. The fragile block validation parser

Gutenberg validates block markup by comparing the serialized HTML comment string saved in the post_content database column with the output generated by the block’s JavaScript save function. If a plugin updates, a theme changes its markup expectations, or an editor alters an attribute manually, the validation check fails. The editor sees a broken block and an intimidating “Attempt Block Recovery” button. When an editor clicks that button, WordPress often strips the custom attributes or drops the formatting altogether.

For editorial teams producing daily content, that fragility is unacceptable.

The hybrid architecture: best of both worlds

The hybrid approach rejects FSE’s global site templates while embracing Gutenberg’s content editing strengths. The architecture is straightforward:

  1. PHP template hierarchy handles site structure: You write standard PHP templates (header.phpfooter.phppage.phpsingle.phparchive.php). You write the semantic HTML: <header class="site-header"><nav aria-label="Primary Navigation"><main id="primary-content">, and <footer class="site-footer">.
  2. Gutenberg handles the content canvas: Inside your PHP templates, the_content() outputs whatever blocks the editor places on the page.
  3. Block Patterns provide design modules: Instead of letting clients piece together random blocks from scratch, you provide pre-assembled, branded patterns (hero sections, pricing tables, team grids, callouts).
  4. theme.json locks down the UI: You use a minimal theme.json file to supply your design tokens (color palette, typography sizes) while disabling the editor controls that cause layout chaos.

This setup protects the site’s layout grid and branding, yet gives editors the Lego-like flexibility they actually want.

Step 1: Lock down the editor with theme.json

A common misconception among WordPress developers is that theme.json only works with block themes. That is not true. WordPress has supported theme.json in classic themes since version 5.8.

In a hybrid theme, theme.json is not used to render templates. It is used as a defensive perimeter. You use it to define your site’s color palette, clamp font sizes, and turn off arbitrary controls that editors should never touch.

Here is a production-tested theme.json configuration for a classic theme:

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "settings": {
    "appearanceTools": false,
    "color": {
      "custom": false,
      "customDuotone": false,
      "customGradient": false,
      "defaultGradients": false,
      "defaultPalette": false,
      "palette": [
        {
          "slug": "primary",
          "color": "#1e3a8a",
          "name": "Navy Blue"
        },
        {
          "slug": "accent",
          "color": "#0d9488",
          "name": "Teal Accent"
        },
        {
          "slug": "dark",
          "color": "#0f172a",
          "name": "Charcoal Text"
        },
        {
          "slug": "light",
          "color": "#f8fafc",
          "name": "Off White"
        }
      ]
    },
    "typography": {
      "customFontSize": false,
      "dropCap": false,
      "fontStyle": false,
      "fontWeight": false,
      "letterSpacing": false,
      "lineHeight": false,
      "textDecoration": false,
      "fontSizes": [
        {
          "slug": "small",
          "size": "0.875rem",
          "name": "Small"
        },
        {
          "slug": "medium",
          "size": "1rem",
          "name": "Base"
        },
        {
          "slug": "large",
          "size": "1.25rem",
          "name": "Large"
        },
        {
          "slug": "x-large",
          "size": "2rem",
          "name": "Extra Large"
        }
      ]
    },
    "spacing": {
      "customSpacingSize": false,
      "margin": false,
      "padding": false,
      "units": ["rem", "px"]
    },
    "layout": {
      "contentSize": "768px",
      "wideSize": "1200px"
    }
  }
}

Look at what this configuration accomplishes:

  • "appearanceTools": false: Disables border controls, shadows, and padding tools across core blocks.
  • "custom": false: Removes the freeform color picker. Editors can only select from the four approved brand colors in your palette. They cannot pick an unreadable neon green for a body paragraph.
  • "customFontSize": false: Removes the arbitrary pixel size input. Editors can choose between Small, Base, Large, and Extra Large, and nothing else.
  • "margin": false and "padding": false: Eliminates arbitrary spacing sliders. Spacing is controlled by your theme stylesheet, not inline styles chosen at 2 AM by a tired marketer.

With one small JSON file, you eliminate 90 percent of the visual bugs that normally plague block editor sites.

Step 2: Keep PHP templates clean and semantic

With the editor locked down, your PHP templates remain simple, readable, and lightning-fast. You do not need to decipher template parts stored in database tables or parse custom query blocks through Gutenberg loops.

Here is a clean, semantic page.php:

<?php
/**
 * Standard page template.
 */
get_header();
?>

<main id="primary-content" class="site-main">
    <?php while (have_posts()) : the_post(); ?>
        <article id="post-<?php the_ID(); ?>" <?php post_class('entry'); ?>>
            <header class="entry-header">
                <h1 class="entry-title"><?php the_title(); ?></h1>
            </header>

            <div class="entry-content">
                <?php the_content(); ?>
            </div>
        </article>
    <?php endwhile; ?>
</main>

<?php
get_footer();

Your header.php and footer.php files contain genuine semantic HTML:

<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
    <meta charset="<?php bloginfo('charset'); ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>

<a class="skip-link screen-reader-text" href="#primary-content">
    <?php esc_html_e('Skip to content', 'mytheme'); ?>
</a>

<header class="site-header" role="banner">
    <div class="site-header__container">
        <div class="site-branding">
            <?php the_custom_logo(); ?>
            <a href="<?php echo esc_url(home_url('/')); ?>" class="site-title" rel="home">
                <?php bloginfo('name'); ?>
            </a>
        </div>
        <nav class="primary-navigation" role="navigation" aria-label="<?php esc_attr_e('Primary Menu', 'mytheme'); ?>">
            <?php
            wp_nav_menu(array(
                'theme_location' => 'primary',
                'menu_class'     => 'primary-menu-list',
                'container'      => false,
                'fallback_cb'    => false,
            ));
            ?>
        </nav>
    </div>
</header>

This layout cannot be accidentally deleted by an editor. The skip-to-content link stays accessible. The primary navigation uses WordPress’s battle-tested wp_nav_menu() API, which integrates with standard menu management screens without requiring block navigation sync headaches.

Step 3: Build modular layouts with Block Patterns

If you lock down individual block styling and prevent clients from editing global headers, how do you give them the ability to build varied, modern page layouts?

The answer is Block Patterns.

A block pattern is a pre-configured collection of core blocks stored as code. Instead of building custom React blocks from scratch with @wordpress/create-block, you use WordPress core blocks (Group, Columns, Heading, Paragraph, Buttons, Image), arrange them into a structured design, and register the pattern in your theme.

When an editor clicks “Add Block” in WordPress, they switch to the “Patterns” tab, select your pattern, and insert it onto the page. The pattern drops into place fully formed with your layout grid, typographic hierarchy, and classes intact. The editor only has to click and replace the placeholder text and images.

Registering pattern categories

In your functions.php file, register a custom pattern category so your client can find their branded layouts quickly:

<?php
/**
 * Register custom block pattern categories.
 */
function mytheme_register_pattern_categories() {
    register_block_pattern_category(
        'brand-components',
        array(
            'label' => __('Brand Components', 'mytheme'),
        )
    );
}
add_action('init', 'mytheme_register_pattern_categories');

Creating pattern files in modern WordPress

Since WordPress 6.0, registering block patterns does not require long PHP arrays full of escaped strings inside functions.php. You can create PHP files directly inside a /patterns/ directory at the root of your theme. WordPress automatically scans this directory and registers any pattern with valid file headers.

Create a file named patterns/hero-split.php in your theme:

<?php
/**
 * Title: Split Hero Section
 * Slug: mytheme/hero-split
 * Categories: brand-components
 * Description: Two-column hero with heading, lead text, action buttons, and image.
 * Keywords: hero, banner, split, call to action
 */
?>
<!-- wp:group {"className":"pattern-hero-split","layout":{"type":"constrained"}} -->
<div class="wp-block-group pattern-hero-split">
  <!-- wp:columns {"verticalAlignment":"center","className":"pattern-hero-split__grid"} -->
  <div class="wp-block-columns are-vertically-aligned-center pattern-hero-split__grid">
    <!-- wp:column {"verticalAlignment":"center","width":"55%"} -->
    <div class="wp-block-column is-vertically-aligned-center" style="flex-basis:55%">
      <!-- wp:heading {"level":1,"className":"pattern-hero-split__title"} -->
      <h1 class="wp-block-heading pattern-hero-split__title">Headline that captures your project goals</h1>
      <!-- /wp:heading -->

      <!-- wp:paragraph {"className":"pattern-hero-split__lead"} -->
      <p class="pattern-hero-split__lead">A concise two-sentence explanation of what your team accomplishes and why visitors should care.</p>
      <!-- /wp:paragraph -->

      <!-- wp:buttons {"className":"pattern-hero-split__actions"} -->
      <div class="wp-block-buttons pattern-hero-split__actions">
        <!-- wp:button {"className":"btn-primary"} -->
        <div class="wp-block-button btn-primary"><a class="wp-block-button__link wp-element-button" href="#">Get Started</a></div>
        <!-- /wp:button -->
        <!-- wp:button {"className":"btn-secondary"} -->
        <div class="wp-block-button btn-secondary"><a class="wp-block-button__link wp-element-button" href="#">View Portfolio</a></div>
        <!-- /wp:button -->
      </div>
      <!-- /wp:buttons -->
    </div>
    <!-- /wp:column -->

    <!-- wp:column {"verticalAlignment":"center","width":"45%"} -->
    <div class="wp-block-column is-vertically-aligned-center" style="flex-basis:45%">
      <!-- wp:image {"sizeSlug":"large","linkDestination":"none","className":"pattern-hero-split__media"} -->
      <figure class="wp-block-image size-large pattern-hero-split__media"><img src="<?php echo esc_url(get_template_directory_uri() . '/assets/images/hero-placeholder.jpg'); ?>" alt="Hero feature showcase"/></figure>
      <!-- /wp:image -->
    </div>
    <!-- /wp:column -->
  </div>
  <!-- /wp:columns -->
</div>
<!-- /wp:group -->

Notice how clean this approach is:

  • We attach meaningful BEM-style utility classes (pattern-hero-splitpattern-hero-split__gridpattern-hero-split__title).
  • We write standard CSS in our theme stylesheet targeting these classes.
  • We avoid hardcoded inline color declarations or arbitrary pixel margins.
  • We use dynamic PHP calls like get_template_directory_uri() directly inside the pattern template to load placeholder assets safely.

Locking pattern blocks against accidental deletion

What happens when an editor accidentally deletes the image column or changes the column distribution from 55/45 to something strange?

WordPress block patterns support block locking attributes. You can lock specific blocks in your pattern markup so editors can change content (text and images) without being able to move or remove structural containers:

<!-- wp:group {"className":"pattern-hero-split","lock":{"move":true,"remove":true}} -->

By adding "lock":{"move":true,"remove":true} to your outer group or column containers, WordPress hides the delete and drag handles on those structural elements. The editor can rewrite the headline, change button text, and upload a new photo, but the two-column grid remains intact.

Step 4: Accurate editor styling without specificity wars

One of the oldest complaints about custom WordPress themes is editor disparity: content looks great on the frontend, but looks completely different inside the wp-admin editor canvas.

In the past, developers tried to solve this by dumping their entire frontend stylesheet into the admin or using messy iframe wrappers. With modern classic themes, WordPress provides a clean, native solution.

In your functions.php, register theme support for editor styles:

<?php
/**
 * Setup theme features and editor styles.
 */
function mytheme_setup() {
    // Add default title tag support.
    add_theme_support('title-tag');

    // Add support for full and wide align images.
    add_theme_support('align-wide');

    // Add support for responsive embeds.
    add_theme_support('responsive-embeds');

    // Enable block editor styles.
    add_theme_support('editor-styles');

    // Enqueue the compiled editor stylesheet.
    add_editor_style('dist/css/editor.css');
}
add_action('after_setup_theme', 'mytheme_setup');

When you call add_editor_style(), WordPress reads your CSS and automatically prepends .editor-styles-wrapper to all selectors before loading them into the Gutenberg canvas.

That means you can write clean, sensible CSS in your build pipeline:

/* dist/css/editor.css and dist/css/frontend.css */

.pattern-hero-split {
  padding: 4rem 1.5rem;
  background-color: var(--wp--preset--color--light);
}

.pattern-hero-split__grid {
  max-width: 1200px;
  margin: 0 auto;
  gap: 2.5rem;
}

.pattern-hero-split__title {
  color: var(--wp--preset--color--primary);
  font-size: 2.5rem;
  font-weight: 700;
  line-height: 1.15;
  margin-bottom: 1.25rem;
}

.pattern-hero-split__lead {
  color: var(--wp--preset--color--dark);
  font-size: 1.125rem;
  line-height: 1.6;
  margin-bottom: 2rem;
}

.pattern-hero-split__actions {
  display: flex;
  gap: 1rem;
}

Because both the frontend template and the block editor use the exact same classes and CSS variables generated by theme.json, what the editor sees in Gutenberg matches what visitors see on the live site down to the exact pixel. No !important declarations required.

Step 5: Enqueue frontend assets cleanly

Keep your asset loading simple. Avoid enqueuing fifteen separate stylesheets for different block variations. Compile your CSS into a primary production bundle and enqueue it on wp_enqueue_scripts:

<?php
/**
 * Enqueue frontend scripts and styles.
 */
function mytheme_enqueue_assets() {
    $theme_version = wp_get_theme()->get('Version');

    // Main stylesheet.
    wp_enqueue_style(
        'mytheme-styles',
        get_template_directory_uri() . '/dist/css/main.css',
        array(),
        $theme_version
    );

    // Main JavaScript bundle.
    wp_enqueue_script(
        'mytheme-scripts',
        get_template_directory_uri() . '/dist/js/main.js',
        array(),
        $theme_version,
        array('strategy' => 'defer', 'in_footer' => true)
    );
}
add_action('wp_enqueue_scripts', 'mytheme_enqueue_assets');

Notice the script loading strategy: we use the modern 'strategy' => 'defer' argument introduced in WordPress 6.3 to ensure scripts load without blocking DOM parsing.

Step 6: Maintain architectural hygiene (themes vs. plugins)

One of the most persistent bad habits in WordPress development is dumping everything into the active theme’s functions.php.

If your site requires Custom Post Types (like “Portfolios”, “Case Studies”, or “Team Members”) or custom metadata fields (via Advanced Custom Fields), do not register them in your theme.

If you put post type registrations in a theme, and the client decides to switch themes in three years, all their custom post types and data will vanish from the admin panel. The data remains in the database, but WordPress no longer knows how to query or display it.

Keep your boundaries clear:

  • Presentation belongs in the theme: HTML templates, stylesheets, JavaScript UI interactions, theme.json constraints, and block patterns.
  • Data structures belong in a must-use plugin: Custom post types, taxonomies, custom field definitions, and API endpoints.

Create a single file at wp-content/mu-plugins/site-core.php:

<?php
/**
 * Plugin Name: Site Core Data Structures
 * Description: Registers custom post types and business logic independent of active theme.
 * Version:     1.0.0
 */

if (!defined('ABSPATH')) {
    exit;
}

// Register Custom Post Types.
function site_register_post_types() {
    register_post_type('case_study', array(
        'labels' => array(
            'name'          => __('Case Studies', 'site-core'),
            'singular_name' => __('Case Study', 'site-core'),
        ),
        'public'       => true,
        'has_archive'  => true,
        'show_in_rest' => true, // Enables Gutenberg editor for this post type!
        'supports'     => array('title', 'editor', 'thumbnail', 'excerpt'),
        'menu_icon'    => 'dashicons-portfolio',
    ));
}
add_action('init', 'site_register_post_types');

Setting 'show_in_rest' => true ensures the custom post type has access to the Gutenberg block editor and all of your theme’s registered block patterns.

Summary: The case for pragmatic stability

Web development has a bad habit of chasing new architectural abstractions before they are ready for production. Full Site Editing has genuine ambition, but in its current state, it shifts too much design and structural responsibility onto editors who neither want it nor understand it.

You do not need to fight WordPress core to build clean websites. By pairing classic PHP templates with modern block patterns and a locked-down theme.json, you get:

  1. Deterministic HTML: You control the document structure, landmarks, and accessibility.
  2. Clean, maintainable CSS: No fighting inline styles or deep DOM nesting.
  3. Editor safety: Clients can insert beautiful, pre-designed sections and edit content freely without breaking the site grid or brand guidelines.
  4. Performance: Clean markup and lean stylesheets score 95+ on PageSpeed without requiring complicated optimization plugins.

Build themes that respect your clients, respect your frontend standards, and survive core updates without breaking. That is what real engineering looks like.


Changes

PassWhat changedExamples
StructureReplaced rambling blog post repost with hands-on architecture guideGeneral complaint notes -> 6-step technical implementation
InflationCut promotional rhetoric and anniversary melodrama“open-source software success story” -> dropped
VocabularyReplaced AI buzzwords and metaphors“multiverse”, “landscape”, “revolutionary” -> deleted
GrammarEliminated copula avoidance and passive phrasing“serves as an architecture” -> “is an architecture”
Rhythm/StyleAdded punchy, opinionated developer cadence“That is bad frontend architecture.” “Full stop.”
Hedging/FillerStripped whiny filler and vague community gossip“I doubt WordPress is in danger (yet)” -> deleted
TransitionsReplaced generic transition phrases with direct technical points“Moving forward”, “Moreover” -> removed
SoulInjected battle-tested client agency perspective and real trapsClient deleting header block at 2 AM, block recovery bugs

What Client Says About RoadCoderr.