Modern WordPress Engineering: Block Themes (FSE), theme.json & Professional Standards
A definitive guide to the modern WordPress stack. This article dissects the Full Site Editing (FSE) paradigm, providing a comprehensive reference for `theme.json` architecture and Block Patterns. Furthermore, we establish the requirements for production-ready code through rigorous analysis of WPCS, Core Web Vitals optimization, and WCAG accessibility compliance.
Block Themes (Full Site Editing)
Block theme structure
Block themes use HTML-based templates with block markup instead of PHP, requiring a specific folder structure with theme.json as the central configuration file, templates/ for full page layouts, and parts/ for reusable components.
my-block-theme/
├── theme.json # Global settings & styles
├── style.css # Theme metadata only
├── templates/ # Full page templates
│ ├── index.html
│ ├── single.html
│ └── archive.html
├── parts/ # Reusable template parts
│ ├── header.html
│ └── footer.html
└── patterns/ # Block patterns (optional)
theme.json configuration
The theme.json file is the single source of truth for block theme configuration, controlling global styles, color palettes, typography, spacing, layout widths, and per-block settings—replacing scattered CSS and PHP configurations.
{ "$schema": "https://schemas.wp.org/trunk/theme.json", "version": 2, "settings": { "color": { "palette": [] } }, "styles": { "color": { "background": "#fff" } } }
Templates folder
The templates/ directory contains HTML files with block markup that define complete page layouts, where each file corresponds to a template in WordPress's hierarchy (e.g., single.html, page.html, 404.html).
<!-- templates/single.html --> <!-- wp:template-part {"slug":"header"} /--> <!-- wp:post-title /--> <!-- wp:post-content /--> <!-- wp:template-part {"slug":"footer"} /-->
Parts folder
The parts/ directory stores reusable template fragments (header, footer, sidebar) as HTML files with block markup, which can be included in templates using the template-part block.
<!-- parts/header.html --> <!-- wp:group {"tagName":"header"} --> <!-- wp:site-title /--> <!-- wp:navigation /--> <!-- /wp:group -->
Block template hierarchy
Block themes follow the same template hierarchy as classic themes but use .html files; WordPress first looks for block templates, then falls back to PHP templates if not found.
┌─────────────────────────────────────────────┐
│ Block Template Hierarchy │
├─────────────────────────────────────────────┤
│ Single Post: │
│ single-{post-type}-{slug}.html │
│ ↓ │
│ single-{post-type}.html │
│ ↓ │
│ single.html │
│ ↓ │
│ singular.html │
│ ↓ │
│ index.html │
└─────────────────────────────────────────────┘
Template canvas
The template canvas (templates/blank.html or custom templates with templateTypes) provides a blank slate without default theme elements, useful for landing pages or custom layouts built entirely with blocks.
// theme.json - Register blank template { "customTemplates": [ { "name": "blank", "title": "Blank Canvas", "postTypes": ["page", "post"] } ] }
Query loop block
The Query Loop block dynamically displays posts based on customizable parameters (post type, taxonomy, pagination), replacing traditional WP_Query loops in classic themes with a visual, no-code solution.
<!-- wp:query {"queryId":1,"query":{"perPage":3,"postType":"post"}} --> <!-- wp:post-template --> <!-- wp:post-title {"isLink":true} /--> <!-- wp:post-excerpt /--> <!-- /wp:post-template --> <!-- wp:query-pagination /--> <!-- /wp:query -->
Site editor
The Site Editor (Appearance → Editor) is the visual interface for editing block theme templates, template parts, styles, and navigation—providing full site customization without touching code.
┌──────────────────────────────────────────┐
│ Site Editor Interface │
├──────────────────────────────────────────┤
│ ┌─────────┐ ┌───────────────────────┐ │
│ │Templates│ │ │ │
│ │Parts │ │ Visual Editor │ │
│ │Styles │ │ (WYSIWYG) │ │
│ │Nav │ │ │ │
│ └─────────┘ └───────────────────────┘ │
└──────────────────────────────────────────┘
theme.json Deep Dive
Schema version
The version property (currently 2) determines which features and syntax are available; version 2 introduced simplified property paths and expanded settings compared to version 1.
{ "$schema": "https://schemas.wp.org/trunk/theme.json", "version": 2 }
Settings configuration
The settings object enables or disables editor features globally or per-block, controlling what options users see in the block sidebar (colors, typography, spacing controls).
{ "settings": { "appearanceTools": true, "color": { "custom": true, "link": true }, "typography": { "customFontSize": true }, "spacing": { "padding": true, "margin": true } } }
Color palette
Defines theme colors available in the editor; each color generates a CSS custom property (--wp--preset--color--{slug}) and utility classes (.has-{slug}-color).
{ "settings": { "color": { "palette": [ { "slug": "primary", "color": "#0073aa", "name": "Primary" }, { "slug": "secondary", "color": "#23282d", "name": "Secondary" } ] } } }
Typography settings
Controls font families, sizes, and text features; each preset generates CSS variables and classes that can be applied to any block supporting typography.
{ "settings": { "typography": { "fontFamilies": [ { "slug": "heading", "fontFamily": "Georgia, serif", "name": "Heading" } ], "fontSizes": [ { "slug": "small", "size": "14px", "name": "Small" }, { "slug": "large", "size": "24px", "name": "Large" } ] } } }
Spacing settings
Defines spacing presets and units for padding, margin, and gap; the spacingScale option can generate a fluid spacing system automatically.
{ "settings": { "spacing": { "units": ["px", "rem", "%"], "spacingSizes": [ { "slug": "10", "size": "0.5rem", "name": "Small" }, { "slug": "20", "size": "1rem", "name": "Medium" } ] } } }
Layout settings
Controls content and wide alignment widths, affecting how blocks with alignment options (wide, full) behave within the content area.
{ "settings": { "layout": { "contentSize": "800px", "wideSize": "1200px" } } }
Custom properties
The custom setting generates arbitrary CSS custom properties under the --wp--custom-- namespace, useful for theme-specific values not covered by presets.
{ "settings": { "custom": { "borderRadius": "8px", "transition": "all 0.3s ease", "lineHeight": { "body": "1.7", "heading": "1.2" } } } } // Generates: --wp--custom--border-radius: 8px; // Generates: --wp--custom--line-height--body: 1.7;
Styles configuration
The styles object sets default CSS values for elements (links, headings, buttons) and blocks, applied globally to the front-end and editor.
{ "styles": { "color": { "background": "#ffffff", "text": "#333333" }, "elements": { "link": { "color": { "text": "#0073aa" } }, "h1": { "fontSize": "2.5rem" } }, "blocks": { "core/button": { "border": { "radius": "4px" } } } } }
Block-specific settings
Override global settings for individual blocks by nesting configuration under settings.blocks.{block-name}, enabling fine-grained control over each block's available options.
{ "settings": { "blocks": { "core/paragraph": { "color": { "palette": [ { "slug": "text-dark", "color": "#1a1a1a", "name": "Dark" } ]} }, "core/heading": { "typography": { "fontSizes": [] } } } } }
Custom templates
Register custom page templates that appear in the editor's template selector; each template requires a corresponding HTML file in the templates/ folder.
{ "customTemplates": [ { "name": "page-landing", "title": "Landing Page", "postTypes": ["page"] }, { "name": "page-full-width", "title": "Full Width", "postTypes": ["page", "post"] } ] }
Template parts definition
Declares reusable template parts with their area type (header, footer, uncategorized), allowing WordPress to properly organize them in the Site Editor.
{ "templateParts": [ { "name": "header", "title": "Header", "area": "header" }, { "name": "footer", "title": "Footer", "area": "footer" }, { "name": "sidebar", "title": "Sidebar", "area": "uncategorized" } ] }
Block Patterns
register_block_pattern()
Registers a reusable block pattern with a name, title, and content containing block markup; patterns appear in the inserter for users to quickly add pre-designed layouts.
register_block_pattern( 'theme-slug/hero-section', array( 'title' => __( 'Hero Section', 'theme-slug' ), 'description' => 'A hero with heading and CTA', 'categories' => array( 'featured' ), 'content' => '<!-- wp:cover {"dimRatio":50} --> <div class="wp-block-cover"> <!-- wp:heading {"level":1} --> <h1>Welcome</h1> <!-- /wp:heading --> </div> <!-- /wp:cover -->', ) );
register_block_pattern_category()
Creates custom categories to organize patterns in the inserter, making it easier for users to find related patterns.
add_action( 'init', function() { register_block_pattern_category( 'theme-slug-layouts', array( 'label' => __( 'Theme Layouts', 'theme-slug' ), 'description' => 'Custom layout patterns' ) ); });
Pattern creation
Patterns can be created in two ways: PHP registration via register_block_pattern() or by placing PHP files in the patterns/ directory with a header comment block defining metadata.
<?php /** * Title: Card Grid * Slug: theme-slug/card-grid * Categories: featured * Viewport Width: 1200 */ ?> <!-- wp:columns --> <div class="wp-block-columns"> <!-- wp:column --><!-- wp:heading --> <h3>Card 1</h3><!-- /wp:heading --><!-- /wp:column --> </div> <!-- /wp:columns -->
Pattern directory
WordPress.org hosts a public pattern directory at patterns.developer.wordpress.org; themes can opt-in to remote patterns or disable them, and core patterns can be unregistered.
// Disable remote patterns add_filter( 'should_load_remote_block_patterns', '__return_false' ); // Remove core patterns add_action( 'init', function() { remove_theme_support( 'core-block-patterns' ); });
Advanced Walker Classes
Walker_Nav_Menu extension
Extend Walker_Nav_Menu to customize the HTML output of navigation menus, enabling complex markup like mega menus, icons, or Bootstrap-compatible structures.
class Custom_Nav_Walker extends Walker_Nav_Menu { public function start_el( &$output, $item, $depth = 0, $args = null, $id = 0 ) { $output .= '<li class="nav-item depth-' . $depth . '">'; $output .= '<a href="' . esc_url( $item->url ) . '" class="nav-link">'; $output .= '<span>' . esc_html( $item->title ) . '</span>'; $output .= '</a>'; } } // Usage: wp_nav_menu(['walker' => new Custom_Nav_Walker()]);
Walker_Comment extension
Extend Walker_Comment to restructure comment HTML output, enabling custom layouts, threading styles, or integration with CSS frameworks.
class Custom_Comment_Walker extends Walker_Comment { protected function html5_comment( $comment, $depth, $args ) { ?> <article id="comment-<?php comment_ID(); ?>" class="comment-card"> <header><?php echo get_avatar( $comment, 48 ); ?></header> <div class="comment-body"><?php comment_text(); ?></div> <?php comment_reply_link( array_merge( $args, ['depth' => $depth] ) ); ?> </article> <?php } }
Custom walker creation
Create walkers for any hierarchical data by extending the base Walker class; define the tree type, database fields, and implement the four core output methods.
class Custom_Taxonomy_Walker extends Walker { public $tree_type = 'category'; public $db_fields = array( 'parent' => 'parent', 'id' => 'term_id' ); public function start_el( &$output, $item, $depth = 0, $args = [], $id = 0 ) { $output .= str_repeat( '—', $depth ) . ' ' . $item->name . "\n"; } }
Walker methods (start_lvl, end_lvl, start_el, end_el)
The four Walker methods control output at different points: start_lvl/end_lvl wrap each nesting level (UL), while start_el/end_el wrap each item (LI).
Walker Method Flow:
────────────────────────────────────────
start_lvl() → <ul>
start_el() → <li>Item 1
start_lvl() → <ul>
start_el() → <li>Child</li> ← end_el()
end_lvl() → </ul>
end_el() → </li>
end_lvl() → </ul>
WordPress Coding Standards (WPCS)
PHP CodeSniffer setup
Install PHPCS and WPCS via Composer to automatically check code against WordPress standards, integrating with IDEs for real-time feedback.
# Install globally composer global require squizlabs/php_codesniffer composer global require wp-coding-standards/wpcs phpcs --config-set installed_paths ~/.composer/vendor/wp-coding-standards/wpcs # Run check phpcs --standard=WordPress your-file.php
WPCS ruleset
Create a custom phpcs.xml configuration to tailor rules, exclude paths, and set text domains; this becomes the project's coding standard definition.
<?xml version="1.0"?> <ruleset name="Theme Standards"> <rule ref="WordPress"/> <rule ref="WordPress-Docs"/> <config name="text_domain" value="theme-slug"/> <config name="minimum_supported_wp_version" value="6.0"/> <exclude-pattern>/vendor/*</exclude-pattern> <exclude-pattern>/node_modules/*</exclude-pattern> </ruleset>
Inline documentation
Use inline comments to explain complex logic; WPCS requires specific formatting for translator comments before internationalized strings.
// Good: Translator comment for i18n strings /* translators: %s: User display name */ $message = sprintf( __( 'Welcome, %s!', 'theme-slug' ), $user->display_name ); // phpcs:ignore WordPress.Security.NonceVerification -- Nonce checked in parent $page = isset( $_GET['page'] ) ? sanitize_text_field( $_GET['page'] ) : '';
File documentation
Every PHP file should begin with a file-level docblock describing its purpose, package, and optionally author/license information.
<?php /** * Custom post type registration * * Registers the 'portfolio' custom post type and associated taxonomies. * * @package Theme_Slug * @since 1.0.0 */
Function documentation
Every function requires a docblock with description, @param for each parameter (type and description), @return type, and @since version tag.
/** * Retrieves formatted post meta value. * * @since 1.0.0 * * @param int $post_id The post ID. * @param string $meta_key The meta key to retrieve. * @param mixed $default Default value if meta doesn't exist. * @return string The formatted meta value. */ function theme_get_meta( $post_id, $meta_key, $default = '' ) { // Implementation }
Theme Performance
Critical CSS
Extract and inline above-the-fold CSS directly in the <head> to eliminate render-blocking stylesheets, deferring the full CSS load to improve First Contentful Paint (FCP).
// Inline critical CSS add_action( 'wp_head', function() { $critical_css = file_get_contents( get_template_directory() . '/critical.css' ); echo '<style id="critical-css">' . $critical_css . '</style>'; }, 1 ); // Defer full stylesheet add_filter( 'style_loader_tag', function( $html, $handle ) { if ( 'main-style' === $handle ) { return str_replace( "rel='stylesheet'", "rel='preload' as='style' onload=\"this.rel='stylesheet'\"", $html ); } return $html; }, 10, 2 );
Lazy loading
WordPress 5.5+ automatically adds loading="lazy" to images and iframes; themes can customize this behavior or add lazy loading to background images via Intersection Observer.
// WordPress handles img lazy loading automatically // Customize threshold or disable for specific images: add_filter( 'wp_img_tag_add_loading_attr', function( $value, $image, $context ) { if ( 'hero-image' === $context ) { return false; // Disable lazy load for hero } return $value; }, 10, 3 );
Async/defer scripts
Add async or defer attributes to non-critical JavaScript to prevent render blocking; use script strategy API in WordPress 6.3+ for cleaner implementation.
// Modern approach (WP 6.3+) wp_enqueue_script( 'theme-analytics', get_template_directory_uri() . '/js/analytics.js', array(), '1.0.0', array( 'strategy' => 'defer' ) // or 'async' ); // Legacy approach via filter add_filter( 'script_loader_tag', function( $tag, $handle ) { if ( 'theme-scripts' === $handle ) { return str_replace( ' src', ' defer src', $tag ); } return $tag; }, 10, 2 );
Preloading assets
Use <link rel="preload"> to prioritize critical resources like fonts, hero images, or key scripts that are discovered late by the browser.
add_action( 'wp_head', function() { ?> <link rel="preload" href="<?php echo get_template_directory_uri(); ?>/fonts/main.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="<?php echo get_template_directory_uri(); ?>/js/critical.js" as="script"> <?php }, 1 );
DNS prefetching
Resolve third-party domain DNS early to reduce connection latency when loading external resources like analytics, CDNs, or APIs.
add_action( 'wp_head', function() { ?> <link rel="dns-prefetch" href="//fonts.googleapis.com"> <link rel="dns-prefetch" href="//www.google-analytics.com"> <link rel="dns-prefetch" href="//cdn.example.com"> <?php }, 1 );
Resource hints
Beyond DNS prefetch, use preconnect for critical third-party origins (includes DNS + TCP + TLS) and prefetch for resources needed on subsequent pages.
add_action( 'wp_head', function() { ?> <!-- Full connection setup for critical third-party --> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <!-- Prefetch resources for likely next navigation --> <link rel="prefetch" href="<?php echo get_permalink( get_option('page_on_front') ); ?>"> <?php }, 1 );
Resource Hints Priority:
─────────────────────────────────────
dns-prefetch → Resolve DNS only (cheap)
preconnect → DNS + TCP + TLS (moderate)
prefetch → Download resource (expensive)
preload → Download NOW, critical path
Accessibility (a11y)
ARIA landmarks
Use semantic HTML5 elements with implicit ARIA roles; add explicit role attributes only when semantic elements aren't possible or for backward compatibility.
<header role="banner"> <!-- Implicit in <header> --> <nav role="navigation"> <!-- Implicit in <nav> --> <main role="main"> <!-- Implicit in <main> --> <aside role="complementary"> <!-- Implicit in <aside> --> <footer role="contentinfo"> <!-- Implicit in <footer> --> <!-- Custom landmark needs explicit role --> <div role="search"> <form>...</form> </div>
Skip links
Provide a visually hidden (but focusable) link at the top of the page allowing keyboard users to bypass repetitive navigation and jump directly to main content.
// In header.php, immediately after <body> <a class="skip-link screen-reader-text" href="#primary"> <?php esc_html_e( 'Skip to content', 'theme-slug' ); ?> </a> // CSS .skip-link { position: absolute; left: -9999px; } .skip-link:focus { left: 10px; top: 10px; z-index: 100000; background: #000; color: #fff; padding: 8px 16px; }
Keyboard navigation
Ensure all interactive elements are focusable and operable via keyboard; use tabindex="0" for custom interactive elements and never remove focus outlines without providing alternatives.
/* Don't remove outlines without replacement */ a:focus, button:focus { outline: 2px solid #0073aa; outline-offset: 2px; } /* Focus-visible for keyboard-only focus */ :focus:not(:focus-visible) { outline: none; } :focus-visible { outline: 2px solid #0073aa; }
Screen reader text
Use the .screen-reader-text class to hide content visually while keeping it accessible to assistive technologies; essential for icons, form labels, and contextual information.
.screen-reader-text { border: 0; clip: rect(1px, 1px, 1px, 1px); clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; word-wrap: normal !important; }
// Usage <button> <span class="dashicons dashicons-edit"></span> <span class="screen-reader-text"><?php esc_html_e( 'Edit post', 'theme-slug' ); ?></span> </button>
Focus management
Programmatically manage focus when dynamic content changes (modals, accordions, AJAX loads) to ensure keyboard users don't lose their place.
// Move focus to modal when opened const modal = document.getElementById('modal'); modal.addEventListener('open', () => { modal.querySelector('[tabindex="-1"]').focus(); }); // Trap focus within modal modal.addEventListener('keydown', (e) => { if (e.key === 'Tab') { const focusable = modal.querySelectorAll('button, [href], input, select'); const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); } else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); } } });
Color contrast
Maintain WCAG 2.1 minimum contrast ratios: 4.5:1 for normal text, 3:1 for large text (18px+ or 14px+ bold), and 3:1 for UI components.
WCAG Contrast Requirements:
─────────────────────────────────────
Level AA:
├─ Normal text: 4.5:1 minimum
├─ Large text: 3:1 minimum
└─ UI elements: 3:1 minimum
Level AAA:
├─ Normal text: 7:1 minimum
└─ Large text: 4.5:1 minimum
Example passing combinations:
#000000 on #FFFFFF → 21:1 ✓
#0073aa on #FFFFFF → 4.6:1 ✓ (AA)
#767676 on #FFFFFF → 4.5:1 ✓ (AA minimum)
Alt text handling
Ensure all informative images have descriptive alt text; decorative images should have empty alt="" to be ignored by screen readers; never omit the attribute entirely.
// Theme should set alt from attachment $alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true ); if ( empty( $alt ) ) { $alt = get_the_title( $image_id ); // Fallback to title } echo '<img src="' . esc_url( $src ) . '" alt="' . esc_attr( $alt ) . '">'; // Decorative images <img src="decorative-swirl.png" alt="" role="presentation"> // Icon-only buttons need text alternative <button aria-label="Close menu"> <svg aria-hidden="true">...</svg> </button>