How to Build Custom Blocks for Block Themes Using the WordPress Block API

Block themes and custom blocks live in the same architecture, but they are not automatically connected. A block theme can use core blocks only and never ship a single custom block. A custom block can be registered by a plugin and used in any theme regardless of whether that theme is a block theme or a classic theme. The interesting work, and the work I get hired for most often these days, is shipping custom blocks as part of a block theme so the theme has distinctive functionality without requiring a companion plugin the client has to install separately.

I have built theme-bundled blocks for maybe twenty client projects in the last two years, and the patterns are now stable enough that I can write them down with confidence. This guide walks through exactly that: what belongs in the theme, what belongs in a plugin, how to structure the block code, and how to make theme.json settings drive block output so the block inherits design changes automatically. If you have ever shipped a theme and then watched a client move to a different theme and lose all the custom blocks, this is the post that prevents that next time.

Theme-bundled blocks vs plugin-bundled blocks

The rule most of us land on after a few client projects:

  • Theme-bundled blocks are blocks that are tightly tied to the theme’s design system. A custom “Team Member” card, a styled “Testimonial” quote, a bespoke “Pricing Table.” Things the client will only ever use on this site, with this exact look, and that would not make sense in a different theme.
  • Plugin-bundled blocks are blocks whose data or functionality should outlive a theme change. A booking widget, a product comparison block, a form block, an event listing. Things the client will still want to use after they redesign the site three years from now.

When in doubt, ship from a plugin. The only time theme-bundled blocks make sense is when the design coupling is tight enough that the block is genuinely unusable in a different theme, and even then I sometimes regret bundling them after a year. A useful mental model: if removing the theme would orphan the data, the block belongs in a plugin. If removing the theme would just leave a styling gap, the block can stay with the theme.

Anatomy of a block

A block is one folder with four files in the simplest case, and maybe six files when you add view scripts and styles. The structure is consistent enough that I have a template I copy for every new block.

block.json is the source of truth

Everything WordPress knows about your block comes from block.json. The PHP registration call reads from it, the JavaScript build pipeline reads from it, and the editor introspects it for attributes and supports. Treat it as the canonical declaration and never duplicate values into PHP or JS code that should be in the JSON.

Three things to internalize from that file:

  • apiVersion: 3 is the current default in 2026. Older blocks used 1 or 2, and you still see them in legacy code, but do not start new blocks on anything older.
  • supports is how you get core features for free. Declaring color.background: true enables the color picker in the sidebar without a single line of React code on your side, and the savings add up across a theme with ten blocks.
  • The file: prefixes make WordPress look for these assets relative to the block.json file. Never hand-register scripts separately if block.json can do it for you, because the asset registration logic in WordPress is more correct than what you would write by hand.

Registering blocks from a block theme

In functions.php, the registration is a one-line loop over the blocks/ directory. This is the pattern I use on every theme, and it scales cleanly from one block to thirty.

register_block_type accepts a directory path and reads block.json automatically. Loop over the blocks/ folder once at init, and every block in the folder self-registers without you touching the PHP again. Add a new block folder, and it just works on the next page reload. This is the same pattern I describe in my block registry post, where I get into more depth on what to do when you have fifty or more blocks and the editor starts choking.

Static vs dynamic blocks, and which to default to

A static block has a save.js that returns the HTML to save in post_content. The editor and the frontend render identically because they both execute the same React code, so the static markup is generated once at save time and then served as plain HTML on every subsequent page load.

Static blocks are fast, cacheable at every layer of the stack, and do not break catastrophically if the block is deregistered, because the saved HTML still renders even without the block code present. They are the right default for almost every block I write, and I would estimate 80 percent of the custom blocks I have shipped are static.

A dynamic block has no save.js (or save: null in the index.js file). Instead, render.php is called on every frontend request to produce the HTML output:

Dynamic blocks are the right choice when the output depends on live data: the latest posts, the current user, a product price that changes, a calendar of upcoming events. They are the wrong choice for static content, because they add PHP render cost on every request and the cost compounds when you stack multiple dynamic blocks on a single page. Rule of thumb that has not failed me yet: if the output would be the same the day after a deploy, use a static block.

theme.json integration is the whole point

The point of theme-bundled blocks is that they pick up the theme’s design tokens automatically. If the theme’s primary color changes in theme.json, every block that uses the primary color should change with it, without any code edits. The way you do this is by referencing the CSS variables that WordPress emits from theme.json into your block’s stylesheet:

All CSS variables beginning with --wp--preset-- are emitted from theme.json by WordPress automatically when you declare a palette, a font family, or a spacing scale. Using these variables in your block stylesheet makes the block inherit theme palette and spacing changes without anyone touching the block’s own code. If you set up a proper design token system in theme.json first, then build your blocks on top of those tokens, the entire theme becomes maintainable in a way that custom CSS files never are. This is one of the most underappreciated benefits of building blocks the right way for block themes specifically.

Supports vs custom controls, and when to write each

Every additional sidebar control is a design decision. Before writing a custom InspectorControls panel, check whether supports in block.json gives you the behavior you need, because the supports keyword unlocks core controls that work the way users already expect.

  • Padding and margin? supports.spacing.padding and supports.spacing.margin
  • Background color and text color? supports.color.background and supports.color.text
  • Font family and font size? supports.typography.fontFamily and supports.typography.fontSize
  • Wide and full alignment? supports.align

These give you the native WordPress controls in the sidebar with no React code on your side, and they pick up the theme.json presets automatically. Custom InspectorControls panels are only needed for block-specific attributes that core does not know about, like the team member’s name, role, photo, or a toggle that shows or hides a bio paragraph. If you find yourself writing a custom panel for something that supports could handle, stop and use supports instead.

Block patterns ship with the theme too

Custom blocks are atoms. Block patterns are molecules. A pattern is a pre-composed arrangement of blocks that the user inserts from the pattern library with one click, and patterns are how you give clients a head start on layouts that use your custom blocks correctly.

Register patterns from the theme by dropping PHP files into a /patterns directory:

Each pattern file has a header with metadata and the block markup as the file body:

WordPress auto-registers patterns from the /patterns directory at init with no extra code. The editor shows them in the inserter under the Patterns tab, and your custom block now has an example use case built in that the client can drop onto a page in seconds. If you want to extend the visual variety further, layer in custom block styles on top of the patterns, which gives the client different visual treatments of the same block without you shipping multiple block definitions.

Deprecations, the unglamorous part of block development

When you change a block’s attributes or save output in a way that breaks old saved markup, you have to register a deprecation, otherwise users with old posts will see “This block contains unexpected or invalid content” warnings in the editor and the post will not load cleanly.

Deprecations let old posts render correctly while new posts use the current format, and the migration function automatically updates old attributes to the new structure when an old post is opened in the editor. Skip the deprecation step and you get “This block contains unexpected or invalid content” in the editor for every old post, which is the kind of error that ages a client relationship fast. Always write deprecations when you change attributes or save output.

Testing habits that pay off

Two testing habits that pay off across every block project I have ever shipped:

  1. Save, refresh, verify source. Every time you change save.js or block attributes, load an existing post that uses the block and confirm it still renders without warnings. If it does not, add a deprecation, because the warning means you broke saved markup.
  2. Test in both the block editor and the Site Editor. Block themes mean custom blocks can appear in template parts as well as in posts, and the behavior can differ subtly between the two contexts. I have shipped a block that worked perfectly in posts and silently failed in a template part header because the parent block context was different.

Build tooling, just use wp-scripts

Use @wordpress/scripts for everything. Do not hand-roll a webpack config, do not try to use Vite for a block project until the WordPress integration matures, and do not ship unbuilt source code. Your package.json should look like this:

On theme activation, make sure the build output is committed to the repository or built in CI. Never ship unbuilt source code to production, because the user’s WordPress install does not run npm and will simply fail to load the block.

The shortest happy path for your first block

For your first theme-bundled block, this is the path I would take and the time I would budget:

  1. Run npx @wordpress/create-block into the theme’s blocks/ folder to scaffold the basic file structure
  2. Edit block.json to match your theme’s naming convention and update the title, description, and category
  3. Edit edit.js and save.js for the fields you actually want, removing the boilerplate
  4. Add a couple of supports flags for the common controls you will need, color and spacing at minimum
  5. Reference theme.json CSS variables in style.scss so the block picks up theme tokens automatically
  6. Add the loop-register code to functions.php so the block self-registers

Total time: under an hour for a simple block once you have done it once or twice. You now have a custom block that speaks your theme’s design system and travels with the theme across deployments, and every additional block follows the same pattern. The marginal cost of block number two is much lower than block number one, and by block number five you are doing it on autopilot.

Scroll to Top