All WordPress HTML Templates Forms & Webhooks AI & Tools
WordPress

WordPress Theme Development in 2026: A Modern Starter Workflow

A practitioner’s guide to WordPress theme development in 2026: block vs hybrid themes, theme.json v3, wp-scripts, the Interactivity API and a deploy workflow.

A real working moment illustrating the theme of an article about wordpress theme development. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.
Key Takeaways

  • Block themes are the default choice in 2026, but hybrid themes (block templates plus classic PHP for a few routes) remain legitimate for WooCommerce builds and anything with heavy custom query logic.
  • theme.json v3 should generate 60 to 80 percent of your CSS. If your style.css is still over 40KB, you’re fighting core instead of configuring it.
  • Use @wordpress/scripts for block-bundled JS and a separate lightweight step (or nothing) for theme-level CSS. Two small pipelines beat one clever webpack config.
  • The Interactivity API with viewScriptModule removes the need for jQuery or a framework for 90 percent of front-end behaviour: accordions, filters, tabs, carousels.
  • Ship patterns and style variations, not options panels. Every Customizer control you add is a support ticket with a delay fuse.

Block, hybrid or classic: pick deliberately, once

A full block theme means every template is an HTML file in templates/, the Site Editor can edit all of them, and you have no header.php. A hybrid theme ships block templates for most routes but keeps classic PHP templates where you need them, because WordPress still falls back to the PHP hierarchy for any template you don’t provide as HTML. A classic theme opts out entirely.

Go full block unless you have a specific reason not to. The reasons that still hold up:

  • WooCommerce with heavily customised product pages. The Woo block templates have matured a lot, but if the client’s conversion flow depends on bespoke variation logic, PHP templates are still less work. We cover the performance side of that in WooCommerce speed.
  • Complex archive queries. The Query Loop block handles meta queries awkwardly. A classic archive-event.php with a tuned WP_Query is honest code that a future developer can read.
  • A client team that will never touch the Site Editor. Rare, but it exists. If nobody edits templates, the editing capability is overhead.

What doesn’t justify a classic theme: “the Site Editor confuses clients.” Lock it down instead. You can remove the Styles panel, restrict template editing per role, and ship locked patterns. Confusion is a configuration problem.

A real working moment that fits an article section about

A file layout that survives handover

Keep it boring and predictable. This is what we scaffold on every project:

acme-theme/
├── style.css              # header only, ~15 lines, no rules
├── theme.json             # v3
├── functions.php          # <200 lines, requires from inc/
├── templates/             # index.html, single.html, page.html, 404.html, archive.html, search.html
├── parts/                 # header.html, footer.html, sidebar.html
├── patterns/              # one PHP file per pattern
├── styles/                # style variations: dark.json, editorial.json
├── inc/                   # setup.php, assets.php, blocks.php, query.php
├── src/                   # JS/CSS sources, one folder per custom block
├── assets/                # built output, committed or not (see below)
└── block-templates-legacy/  # delete this when you find it

The rule that matters: functions.php is a router, not a codebase. It requires files from inc/ and does nothing else. The moment someone adds a 40-line helper function directly to it, the file starts growing and you lose the ability to find anything.

Declare your floor in style.css. In 2026 that means Requires at least: 6.6 and Requires PHP: 8.1 at minimum. Go lower than 8.1 and you’re writing defensive code for PHP versions that no longer receive security patches.

theme.json is where most of your CSS should live

This is the single biggest shift, and the one most people skip. Core generates CSS custom properties and utility classes from theme.json. Define your palette, spacing scale and type scale there, and you get editor parity at no extra cost: the controls in the editor sidebar match what the front end actually renders.

{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "settings": {
    "appearanceTools": true,
    "useRootPaddingAwareAlignments": true,
    "layout": { "contentSize": "720px", "wideSize": "1180px" },
    "color": {
      "defaultPalette": false,
      "defaultGradients": false,
      "defaultDuotone": false,
      "palette": [
        { "slug": "base", "color": "#ffffff", "name": "Base" },
        { "slug": "contrast", "color": "#111418", "name": "Contrast" },
        { "slug": "accent", "color": "#2f6df6", "name": "Accent" }
      ]
    },
    "typography": {
      "fluid": true,
      "fontSizes": [
        {
          "slug": "medium", "name": "Medium", "size": "1.0625rem",
          "fluid": { "min": "1rem", "max": "1.125rem" }
        }
      ]
    },
    "spacing": {
      "units": ["rem", "vh", "%"],
      "spacingScale": { "steps": 0 },
      "spacingSizes": [
        { "slug": "30", "size": "1rem", "name": "1" },
        { "slug": "50", "size": "2rem", "name": "2" },
        { "slug": "70", "size": "4rem", "name": "3" }
      ]
    }
  }
}

Two details people miss. Setting defaultPalette and defaultGradients to false removes roughly 20 core colours from the picker, which stops clients building purple sections inside a blue brand. And "spacingScale": { "steps": 0 } disables the auto-generated 7-step scale so your named sizes are the only ones offered.

For anything theme.json genuinely can’t express (a :has() selector, a grid with named areas, a hover transition on a card), write real CSS and attach it per block so it only loads when the block appears:

add_action( 'init', function () {
	wpenqueueblock_style( 'core/quote', array(
		'handle' => 'acme-quote',
		'src'    => getthemefile_uri( 'assets/blocks/quote.css' ),
		// 'path' lets core inline the file when it's under the inline threshold
		'path'   => getthemefile_path( 'assets/blocks/quote.css' ),
		'ver'    => wpgettheme()->get( 'Version' ),
	) );
} );

On a recent build this approach took the render-blocking CSS on the homepage from one 61KB bundle down to 9KB of inlined, used-on-this-page rules. If you’re chasing that number: eliminating render-blocking resources.

Build tooling: wp-scripts, Vite, or nothing

Use @wordpress/scripts for anything that touches the block editor. It handles JSX, the wp-* third-partys, block.json copying and the asset.php dependency files that wpenqueuescript reads. Reimplementing that in Vite is possible. It costs two days and saves nobody anything.

{
  "scripts": {
    "start": "wp-scripts start --experimental-modules",
    "build": "wp-scripts build --experimental-modules",
    "lint:php": "phpcs --standard=WordPress-Extra inc/ patterns/",
    "format": "wp-scripts format"
  }
}

The --experimental-modules flag is what gets you proper ES module output for the Interactivity API. Without it, viewScriptModule entries in block.json won’t be compiled.

For theme-level CSS that isn’t block-specific, a 12-line Sass or Lightning CSS step is plenty. Don’t run your whole theme through webpack for the sake of consistency. The same reasoning we applied to static templates in build tooling for static templates applies here: the smallest pipeline that does the job is the one that still works in 18 months.

Patterns and style variations replace the options panel

A pattern is a PHP file with a comment header. Core registers it automatically from patterns/, so there’s no registerblockpattern() call to maintain.

<?php
/**
  • Title: Hero with media on the right
  • Slug: acme/hero-media-right
  • Categories: acme-hero
  • Keywords: banner, intro, header
  • Viewport Width: 1400
*/ ?> <!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|70"}}}} --> <div class="wp-block-group alignfull"> <!-- wp:heading {"level":1,"fontSize":"xx-large"} --> <h1 class="wp-block-heading has-xx-large-font-size"><?php eschtmle( 'Headline goes here', 'acme' ); ?></h1> <!-- /wp:heading --> </div> <!-- /wp:group -->

Patterns are translatable because they’re PHP. That’s a real advantage over the JSON pattern format, and it matters if you’re doing multilingual work.

Style variations live in styles/*.json, use the same schema as theme.json, and appear in the Styles panel as alternate looks. Shipping a dark variation alongside the default costs about 40 lines of JSON and removes an entire category of “can we have a dark version” requests. Section styles (block style variations defined in theme.json and applied to a Group) let you offer “inverted section” as a one-click choice rather than a colour picker the client will get wrong.

JavaScript: the Interactivity API and nothing else

For front-end behaviour, the Interactivity API is now the default. It’s declarative, it hydrates only the blocks present on the page, and it ships as a core script module so two blocks using it don’t duplicate the runtime.

// src/disclosure/view.js
import { store, getContext } from '@wordpress/interactivity';

store( 'acme/disclosure', {
	actions: {
		toggle() {
			const ctx = getContext();
			ctx.isOpen = ! ctx.isOpen;
		},
	},
} );
<?php // src/disclosure/render.php ?>
<div
	data-wp-interactive="acme/disclosure"
	<?php echo wpinteractivitydatawpcontext( array( 'isOpen' => false ) ); ?>
>
	<button data-wp-on--click="actions.toggle" data-wp-bind--aria-expanded="context.isOpen">
		<?php echo esc_html( $attributes['label'] ?? '' ); ?>
	</button>
	<div data-wp-bind--hidden="!context.isOpen"><?php echo $content; ?></div>
</div>

Register it with "viewScriptModule": "file:./view.js" in block.json using API version 3. For the handful of scripts that live at theme level rather than block level, always pass a loading strategy:

wpenqueuescript(
	'acme-nav',
	getthemefile_uri( 'assets/js/nav.js' ),
	array(),
	wpgettheme()->get( 'Version' ),
	array( 'strategy' => 'defer', 'in_footer' => true )
);

One caveat on the Interactivity API: debugging is worse than plain JS. The directives are strings in markup, so a typo in data-wp-bind--aria-expanded fails silently. Keep the browser console open and check the wp-interactivity store in devtools before you blame your logic.

The local, staging, deploy loop

wp-env (Docker) for anything with database state you care about reproducing. wp-now for a 10-second throwaway when you just want to check a pattern renders. Both read from your theme directory directly, so there’s no sync step.

Version control the theme directory only. Not wp-content, not uploads, not plugins you didn’t write. Manage those with Composer and wpackagist so the plugin set is a lockfile rather than a folder someone zipped. We wrote up the full setup in staging, version control and deploys for WordPress teams.

The decision people stall on: commit built assets or build on deploy? Build on deploy if you control the server and have Node there. Commit the build/ directory if you’re deploying over SFTP to shared hosting, which is still the reality for a lot of small client sites. Committing build output is unfashionable and it works. Just make sure CI fails the build if the committed output differs from a fresh compile, otherwise someone will push stale CSS.

One more thing that pays off late: if the theme will be distributed rather than used on one site, support and update load becomes the dominant cost. We broke that down in shipping a commercial WordPress theme, and it changes architecture decisions, particularly around how many options you expose.

Frequently Asked Questions

Do I need to know React to build a WordPress theme in 2026?

No, not for a theme. A block theme is HTML templates, JSON and a small amount of PHP, with zero React. You only need React if you’re writing custom blocks with a bespoke editor interface, and even then, many custom blocks can be built with the block.json attributes plus a render.php and no custom edit component beyond InspectorControls.

Can I use Tailwind in a block theme?

You can, and we have, but it conflicts with the point of the block editor. Tailwind classes aren’t editable by the client in the Styles panel, so you end up with two styling systems that disagree. If you do use it, restrict Tailwind to custom block markup you fully control and let theme.json own colours, spacing and typography.

Are child themes still the right way to customise a block theme?

Usually not. Site Editor changes are stored in the database as wptemplate and wpglobal_styles posts, so clients can override templates and styles without touching files. Use a child theme only when you need to add PHP, custom blocks, or a build step on top of a parent you don’t control.

How do I stop clients breaking templates in the Site Editor?

Use the templateLock attribute on container blocks in your patterns ("all" or "insert"), and remove the editthemeoptions capability from editor-level roles if they shouldn’t touch global styles. You can also hide the Styles panel entirely with a small blockeditorsettings_all filter. Lock what’s structural, leave content free.

What happened to Advanced Custom Fields in block themes?

It’s still widely used, but the Block Bindings API now covers a lot of what ACF blocks were for: you can bind a core Paragraph or Image block’s content directly to post meta without custom block code. For complex repeating content, a proper custom post type and taxonomy model plus bindings is cleaner than a flexible content field with 14 layouts.

Where to start

If you’re beginning a theme, do this in order: write theme.json first with the real brand palette and type scale, build three patterns, then build templates from those patterns. Templates last. Teams that start with index.html end up retrofitting a design system into markup that’s already shipped.

If you’re maintaining a classic theme and wondering whether to rewrite, measure first. Count your templates and your functions.php lines. Under 10 templates and a tidy codebase, leave it and keep it patched. Over 20 templates with duplicated hero markup, you’ll recover the rebuild cost within two rounds of client change requests.

And if you’d rather not build the foundation at all, CanvasWP ships the block theme layer, patterns and style variations already assembled, which is how we start most client projects that don’t need a bespoke system.

block theme vs hybrid theme wordpress build wordpress theme with theme.json interactivity api viewscriptmodule tutorial wordpress block patterns php file header wordpress theme development wordpress theme development 2026 wordpress theme workflow with wp-scripts wp_enqueue_block_style per block css