How to Build a Custom WordPress Theme in 2026 (Block-First Approach)
Build a custom WordPress theme in 2026 the block-first way: theme.json, parts, patterns, zero page builders. The agency file structure WitsCode ships.
A block-first WordPress theme is a theme built natively against the block editor and the Site Editor: design tokens declared in a single theme.json file, layout written as HTML templates inside /templates/ and /parts/, and repeatable composition shipped as registered block patterns under /patterns/. It carries no third-party page-builder runtime to the front-end and treats Gutenberg as the canonical authoring surface for every editor on the team. That definition is the entire premise of this article. Everything below is the engineering case for that approach and the file structure we put under version control on every WitsCode theme we deliver.
The decision to build block-first in 2026 is no longer architectural taste. The Site Editor reached feature parity with page builders somewhere around WordPress 6.4 and the gap has only widened. Themes that ship a builder runtime now pay a 150 to 250 kilobyte JavaScript tax on every page view to solve a problem the platform solves natively. This is how we replaced them.
Why we build block-first instead of buying a page builder
The honest reason most agencies still ship Elementor or Divi is not technical. Page builders make discovery easier. The client sees the same editor the designer used, the demo importer drops twenty pages of content into place in one click, and the agency invoices for the build. What goes unmentioned is that every one of those decisions has been mortgaged against the future of the site. The builder shortcodes and JSON fragments inside the database become structurally inseparable from the content. Removing the builder a year later is a rebuild, not a refactor.
Block-first themes invert that trade. The authoring surface is the Site Editor, which ships with WordPress core and is going nowhere. Templates are HTML files inside the theme. Patterns are PHP files that register themselves automatically when the theme activates. Design tokens are a single JSON file the version-control system can diff. Nothing about the front-end depends on a plugin runtime that might be discontinued, sold, or relicensed.
The performance arithmetic is the second piece of the case. A vanilla block theme like Twenty Twenty-Four ships roughly 35 kilobytes of CSS and almost no JavaScript on the public front-end. A typical Elementor build delivers around 150 kilobytes of builder JavaScript before the site has rendered a pixel. That gap is not optimisable. It is structural. We could not hit a 1.5-second LCP target on a $35-per-month managed host any other way.
The file structure we ship on every theme
We have a starter repository that every new theme is forked from, and we have not changed its top-level shape in two years because the shape is correct. The directory tree below is what a freshly cloned WitsCode theme looks like before a single byte of project-specific work has happened.
witscode-theme/
style.css
theme.json
functions.php
index.php
screenshot.png
templates/
index.html
single.html
page.html
archive.html
404.html
front-page.html
parts/
header.html
footer.html
sidebar.html
patterns/
hero-split.php
cta-banner.php
feature-grid.php
pricing-three-tier.php
styles/
sober.json
high-contrast.json
assets/
css/app.css
js/app.js
img/
fonts/
inc/
block-styles.php
pattern-categories.php
helpers.php
languages/
composer.json
package.json
.editorconfig
.gitignore
README.md
Two files in that tree surprise developers coming from React projects. style.css is still required even though no front-end CSS is loaded from it; the file exists so WordPress can read the theme metadata header. index.php has been required by the theme review process for every theme since the platform began and is left in place as a fallback for environments with block-template loading disabled. Both are essentially decorative in 2026 and both are still mandatory.
The style.css header is small and we copy the same shape onto every project, varying only the metadata fields that change.
/*
Theme Name: WitsCode Block Theme
Theme URI: https://witscode.com/themes/block
Author: WitsCode
Author URI: https://witscode.com
Description: Agency block-first WordPress theme. No page builder dependency.
Version: 1.0.0
Requires at least: 6.4
Tested up to: 6.7
Requires PHP: 8.1
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Text Domain: witscode
Tags: block-theme, full-site-editing, custom-colors, custom-logo
*/
The inc/ directory holds the small amount of PHP a block theme genuinely needs. Block-style registrations, pattern category definitions, and helper functions live there in one-purpose files that functions.php requires explicitly. The 3,000-line functions.php that drove classic-theme development belongs to the past. A block theme functions.php should fit on a single screen.
Inside theme.json: design tokens as configuration
The theme.json file is the most important single file in the theme and the one most worth understanding deeply. It defines the design tokens the editor exposes to authors, the global default styles that apply to every block, and the schema of customisability the client is allowed to operate inside. Treat it as the constitution of the theme. Everything below is governed by it.
The schema version moved to 3 in WordPress 6.6 and we use it on every new build. Version 3 changed the defaults for defaultFontSizes and defaultSpacingSizes to false, which means the theme is now responsible for declaring its own scale rather than inheriting an opinionated WordPress default. We consider this an improvement. The whole point of building a custom theme is that the design system belongs to the client, not to whichever WordPress release happened most recently.
A trimmed but production-shaped theme.json looks like this:
{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"settings": {
"appearanceTools": true,
"layout": { "contentSize": "720px", "wideSize": "1200px" },
"color": {
"custom": false,
"customDuotone": false,
"palette": [
{ "slug": "ink", "name": "Ink", "color": "#0f172a" },
{ "slug": "paper", "name": "Paper", "color": "#ffffff" },
{ "slug": "brand", "name": "Brand", "color": "#1e6fff" }
]
},
"typography": {
"fluid": true,
"fontFamilies": [
{
"slug": "outfit",
"name": "Outfit",
"fontFamily": "Outfit, system-ui, sans-serif"
}
],
"fontSizes": [
{ "slug": "small", "size": "clamp(0.875rem, 0.85rem + 0.1vw, 0.95rem)", "fluid": true },
{ "slug": "medium", "size": "clamp(1rem, 0.95rem + 0.2vw, 1.125rem)", "fluid": true },
{ "slug": "large", "size": "clamp(1.5rem, 1.3rem + 0.8vw, 2rem)", "fluid": true }
]
},
"spacing": {
"units": ["px", "rem", "em", "%"],
"spacingScale": { "steps": 7 }
},
"blocks": {
"core/button": { "color": { "custom": false } }
}
},
"styles": {
"color": { "background": "var(--wp--preset--color--paper)", "text": "var(--wp--preset--color--ink)" },
"typography": { "fontFamily": "var(--wp--preset--font-family--outfit)", "fontSize": "var(--wp--preset--font-size--medium)", "lineHeight": "1.55" }
},
"templateParts": [
{ "name": "header", "area": "header", "title": "Header" },
{ "name": "footer", "area": "footer", "title": "Footer" }
],
"customTemplates": [
{ "name": "page-wide", "title": "Wide page", "postTypes": ["page"] }
]
}
The opinionated choices are visible in the file. We turn custom to false on the colour palette so authors cannot pick a hex off the colour wheel and break the brand. We declare appearanceTools: true once at the top level rather than enabling spacing, border, link, and shadow tools individually. The colour palette is three slugs and we resist the urge to add a fourth, because every additional palette entry is a decision the editor will eventually make incorrectly.
The styles/ directory at the theme root is a separate feature worth knowing. Any additional theme.json-shaped file inside it becomes a selectable style variation in the Site Editor's Styles panel. Shipping a sober.json and a high-contrast.json takes ten minutes and gives the client a switch they can hit during seasonal promotions or accessibility audits without calling us.
Templates and parts: HTML files Git can diff
The templates folder is where layout lives, and the format is intentionally simple. Each file is plain HTML containing block-comment markup that WordPress parses into rendered blocks at request time. There is no PHP, no template tags, no the_content(). Dynamic data comes from core blocks like wp:post-title, wp:post-content, and wp:query, which know how to fetch the data they need from the global query.
Here is templates/single.html in full. It is short on purpose.
<!-- 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 /-->
<!-- wp:template-part {"slug":"author-bio"} /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
The reason this file matters is that it is genuinely diffable. Every change shows up in git diff as readable block markup. The page-builder equivalent is a 60-kilobyte JSON blob inside the database, which Git cannot inspect and the developer cannot review. A block theme moves layout into the same pull-request workflow that governs the rest of the codebase.
Template parts inside /parts/ work the same way and are referenced by the slug attribute on the wp:template-part block. We add a cta-banner part on most marketing sites and an author-bio part on every site with editorial content. Parts compose; a part can include another part, and the editor will follow the chain.
Block patterns: the composition layer clients actually use
Patterns are the layer that determines whether the client will love or hate the editor. A pattern is a saved arrangement of blocks that an author can insert into any page from the Patterns library. Done well, patterns turn the editor into a curated kit of composable sections that match the design system. Done badly, they expose the empty inserter and let authors recreate every layout from scratch every time.
Since WordPress 6.0, any PHP file inside the theme's /patterns/ directory with the right header comment registers automatically. The header looks like this:
<?php
/**
* Title: Hero, Split with image
* Slug: witscode/hero-split
* Categories: witscode-hero
* Keywords: hero, header, marketing
* Block Types: core/post-content
* Inserter: yes
* Viewport Width: 1280
*/
?>
<!-- wp:columns {"verticalAlignment":"center","align":"wide"} -->
<div class="wp-block-columns alignwide are-vertically-aligned-center">
<!-- wp:column {"width":"55%"} -->
<div class="wp-block-column" style="flex-basis:55%">
<!-- wp:heading {"level":1,"fontSize":"large"} -->
<h1 class="wp-block-heading has-large-font-size">A precise headline goes here.</h1>
<!-- /wp:heading -->
<!-- wp:paragraph -->
<p>One sentence of supporting copy that earns the click below.</p>
<!-- /wp:paragraph -->
<!-- wp:buttons -->
<div class="wp-block-buttons">
<!-- wp:button {"backgroundColor":"brand","textColor":"paper"} -->
<div class="wp-block-button">
<a class="wp-block-button__link has-paper-color has-brand-background-color has-text-color has-background wp-element-button">Start a project</a>
</div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
</div>
<!-- /wp:column -->
<!-- wp:column {"width":"45%"} -->
<div class="wp-block-column" style="flex-basis:45%">
<!-- wp:image {"sizeSlug":"large"} -->
<figure class="wp-block-image size-large"><img src="<?php echo esc_url( get_template_directory_uri() . '/assets/img/hero.jpg' ); ?>" alt="" /></figure>
<!-- /wp:image -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
We register a custom pattern category in inc/pattern-categories.php so our patterns appear under a clearly branded heading rather than landing in the generic "Featured" bucket alongside core defaults. The registration is a single hook on init:
add_action('init', function () {
register_block_pattern_category('witscode-hero', [
'label' => __('WitsCode, Hero', 'witscode'),
]);
register_block_pattern_category('witscode-cta', [
'label' => __('WitsCode, CTA', 'witscode'),
]);
});
The discipline that makes patterns pay off is keeping the library small. Our default build ships eight to twelve patterns, never more, covering the sections the design explicitly drew: a hero, a feature grid, a pricing table, a logo wall, a CTA banner, an author bio, a quote callout, and a contact block. Anything else, the client composes from primitives. Pattern bloat is a real failure mode; an editor with thirty theme patterns plus eighty core defaults plus the WordPress.org directory is an editor where authors give up and copy-paste from existing pages.
Conditional content, queries, and dynamic data
The block editor handles dynamic content through three mechanisms that work together cleanly once you have seen them paired. Core dynamic blocks like wp:post-title, wp:post-content, wp:post-date, and wp:post-author-name resolve their data from the current global query at render time. The wp:query block sets up a custom loop, with a wp:post-template child that defines what each iteration looks like. And the Block Bindings API, introduced in WordPress 6.5 and stabilised since, lets a block attribute pull its value from a custom field, a meta key, or a custom source registered through PHP, without requiring a custom block to be authored.
A simple archive template makes the pattern visible:
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:query {"queryId":1,"query":{"perPage":9,"postType":"post"}} -->
<div class="wp-block-query">
<!-- wp:post-template {"layout":{"type":"grid","columnCount":3}} -->
<!-- wp:post-featured-image {"isLink":true,"aspectRatio":"4/3"} /-->
<!-- wp:post-title {"isLink":true,"level":2} /-->
<!-- wp:post-excerpt /-->
<!-- /wp:post-template -->
<!-- wp:query-pagination -->
<!-- wp:query-pagination-previous /-->
<!-- wp:query-pagination-numbers /-->
<!-- wp:query-pagination-next /-->
<!-- /wp:query-pagination -->
</div>
<!-- /wp:query -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
The Block Bindings API is the piece developers from the ACF era should pay closest attention to. In a classic theme, surfacing a custom field on the front-end meant writing a custom block, registering an ACF block, or sprinkling get_post_meta() inside template files. Bindings replace all three with a JSON attribute on a core block. Bind a paragraph block's content to the subtitle post meta key and the paragraph renders the meta value, edits update it, and no custom block ever needed to exist. For most field-driven content on a marketing site, we reach for bindings before we reach for ACF.
Deploy: from local theme folder to versioned production release
A theme that ships well is a theme that is treated as a versioned package, not as a folder somebody FTP'd into wp-content/themes/. Our deploy pipeline is short and unromantic. The theme is a Composer package in a private Packagist repository, with composer.json declaring it as type: wordpress-theme and depending on the WordPress core packages we expect. Production sites require it through a top-level composer.json at the WordPress root, and wp-cli activates it during the deploy step.
A continuous-integration job runs on every pull request. PHP_CodeSniffer with the WordPress Coding Standards ruleset checks the PHP. The theme.json file is validated against the official WordPress JSON Schema, which catches typos and version mismatches before they reach a client environment. A small Playwright suite hits staging after every merge and asserts that the front-end ships less than the bytes we promised: under 80 kilobytes of CSS per template, under 50 kilobytes of theme JavaScript on the homepage, and an LCP under 1.5 seconds on a throttled connection. The build fails if any of those numbers regress.
Releases are tagged with semantic versions. A CSS fix is a 1.0.x increment, a new pattern is a 1.x.0 increment, and a theme.json schema change or a removed pattern slug is a x.0.0 increment treated the same as a backwards-incompatible API change, with a written upgrade note in the README.md and a quick call with the client before the deploy lands. Themes are infrastructure. They deserve infrastructure-grade release hygiene.
The shape of a custom WordPress theme in 2026 is not exotic. It is a small set of HTML files, one configuration file, a directory of registered patterns, a thin layer of PHP, and a deploy pipeline that treats the whole thing as a versioned package. What has changed is that the platform now provides everything the page-builder generation outsourced to plugins, and the trade-off has finally tipped. Every WitsCode theme ships block-first because that is the WordPress that will still be running, unmodified, on the client's site five years from now. We would rather build for that one.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
WordPress Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss wp page builders, themes & gutenberg for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.