How to build custom WordPress blocks: block.json, create-block and best practices
How to build custom WordPress blocks: block.json, create-block and best practices

A custom block is a unit of content or layout that you add to the WordPress block editor, packaged (usually) as a plugin. The recommended way to build one in 2026 is to describe the block in a block.json metadata file, scaffold the project with the official @wordpress/create-block tool, and decide early whether the block's front-end HTML is saved into the post (a static block) or generated by PHP on each request (a dynamic block).
WordPress has recommended block.json as "the canonical way to register block types" since version 5.8. The create-block package generates a working plugin, a block.json file, editor and front-end code, and a zero-configuration build step through @wordpress/scripts, so most developers can have a block running in the editor within minutes.
As of October 2026 the current release is WordPress 7.1.2. Two recent changes matter for block developers: WordPress 7.0 added PHP-only block registration for simple server-rendered blocks, and WordPress 7.1 always loads the post editor in an iframe, so every block needs to work inside one. This guide covers the file structure, the block.json fields you will actually use, static versus dynamic rendering and the practices that keep blocks maintainable.
Key Takeaways
- Register blocks from a block.json file; it is the canonical registration method for both PHP and JavaScript and lets WordPress load block assets only when the block is on the page.
- Scaffold new blocks with npx @wordpress/create-block@latest, which sets up @wordpress/scripts for building, linting and packaging.
- Choose static rendering for content that rarely changes structure and dynamic rendering (a render.php file) for anything that depends on live data or may change markup later.
- Use apiVersion 3 and test in the iframed editor, because WordPress 7.1 always iframes the post editor.
- WordPress 7.0 lets you register simple, server-rendered blocks entirely in PHP with the autoRegister support flag.
- Escape everything you print in render.php and keep attributes minimal; most bugs come from changed save output and unescaped data.
What a custom block is made of
A typical block lives in its own folder inside a plugin and contains a small set of files. Each one has a clear job:
- block.json: the metadata file. It names the block, declares its attributes and supported features, and points to every script, style and template the block uses.
- index.js: registers the block in the editor and imports the edit and save functions.
- edit.js: the React component that renders the block inside the editor, including toolbar and sidebar (Inspector) controls.
- save.js: for static blocks, returns the markup that is stored in the post content.
- render.php: for dynamic blocks, the PHP template that builds the front-end HTML on each request.
- view.js: optional front-end JavaScript, for example interactivity built with the Interactivity API.
- style.css and editor.css (or SCSS sources): front-end and editor-only styles.
The plugin's main PHP file registers the block on the init hook. Because registration reads block.json, WordPress knows about the block on the server as well as in the editor, which the handbook recommends because the block type REST endpoint "can only list blocks registered on the server."
Scaffolding a block with @wordpress/create-block
The create-block package is the officially supported scaffolding tool. You need Node.js and npm installed. From your site's wp-content/plugins folder (or anywhere, if you plan to use wp-env), run:
- Create the plugin: npx @wordpress/create-block@latest todo-list. The slug becomes the folder name and the internal block name.
- Start the watcher: cd todo-list, then npm start. This rebuilds the block whenever you save a file.
- Activate the plugin under Plugins in wp-admin and insert the block in the editor.
- Build for production: npm run build when you are ready to ship.
Useful options include --variant dynamic (scaffold a dynamic block with render.php), --namespace my-plugin (set the block namespace), --no-plugin (generate only the block files, for adding a second block to an existing plugin), --wp-env (add a local Docker-based WordPress environment) and --template for third-party or local templates. Running npx @wordpress/create-block@latest with no slug starts an interactive mode that asks for each value.
The generated package.json includes scripts for build, start, format, lint:css, lint:js, packages-update and plugin-zip. The generated plugin file registers blocks with wp_register_block_types_from_metadata_collection( __DIR__ . '/build', __DIR__ . '/build/blocks-manifest.php' ), a WordPress 6.8 API that registers every block in the build folder from a single generated manifest instead of reading each block.json file separately. If you register a single block by hand, register_block_type( __DIR__ . '/build/todo-list' ) still works.
The block.json fields that matter most
The block.json reference documents about 30 properties. A minimal, realistic file for a dynamic notice block looks like this: { "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "my-plugin/notice", "title": "Notice", "category": "text", "textdomain": "my-plugin", "attributes": { "message": { "type": "string", "default": "" } }, "supports": { "html": false, "color": { "background": true } }, "editorScript": "file:./index.js", "style": "file:./style.css", "render": "file:./render.php" }.
| Field | What it does | Notes |
|---|---|---|
| $schema | Points editors at the JSON schema | Gives autocomplete and validation in most code editors |
| apiVersion | Block API version | Use 3; version 3 means the block works inside the iframed editor |
| name | Unique namespace/block-name | Changing it later breaks existing content |
| title, category, icon, description, keywords | How the block appears in the inserter | Title, description and keywords are translatable |
| attributes | The block's data and where it is stored | Can be sourced from saved HTML (source, selector) or stored in the block comment |
| supports | Opts in to core features such as color, spacing, typography, align and interactivity | Prefer supports to custom controls; users get familiar UI and theme.json integration |
| usesContext, providesContext | Pass data between parent and child blocks | Used by blocks inside a Query Loop, for example |
| editorScript, script, viewScript, viewScriptModule | JavaScript for the editor, both contexts or the front end | viewScriptModule loads a script module (needed for the Interactivity API) |
| editorStyle, style, viewStyle | CSS for the editor, both contexts or the front end | Front-end assets load only when the block is present (when the theme supports lazy loading assets) |
| render | PHP template for server rendering (since 6.1) | Receives $attributes, $content and $block |
The API versions page notes that WordPress 7.0 decided whether to iframe the post editor based on the blocks in the post, and that WordPress 7.1 "always use[s] the iframe for the post editor, regardless of the apiVersion." In practice, an older apiVersion no longer keeps your block out of the iframe, so test any code that touches window or document directly.
Static vs dynamic blocks
This is the most important design decision for a custom block, and it is hard to reverse once content exists.
| Static block | Dynamic block | |
|---|---|---|
| Where HTML comes from | The save function, stored in post_content when the post is saved | render.php (or a render_callback) on every request |
| Best for | Content that the author controls and that rarely changes structure | Live data (latest posts, prices, user-specific output), or markup you may redesign later |
| Changing markup later | Old posts fail validation unless you add a deprecation | Change render.php and every instance updates |
| Works without the plugin | Saved HTML still displays | Falls back to any saved HTML, otherwise nothing |
| Server cost | None beyond normal page rendering | PHP runs for each instance on each uncached request |
With a static block, the editor compares the stored markup with what save() would produce now. If you change save() without adding an entry to the block's deprecated array (the Block Deprecation API), existing posts show a "This block contains unexpected or invalid content" warning. Dynamic blocks avoid that problem because the editor only stores attributes, and the handbook lists avoiding validation errors as a common reason to choose dynamic rendering.
A safe render.php
For the notice block above, render.php can be as short as two lines of output: <div <?php echo get_block_wrapper_attributes(); ?>> <?php echo esc_html( $attributes['message'] ); ?> </div>. get_block_wrapper_attributes() returns the class names and inline styles generated by the block's supports (color, spacing and so on), already escaped. Everything you add yourself must be escaped for its context: esc_html() for text, esc_attr() for attributes, esc_url() for links and wp_kses_post() for trusted HTML. The handbook warns that render.php "loads for every instance of the block type," so put shared functions and classes in a separate file instead of declaring them in the template.
PHP-only blocks in WordPress 7.0
WordPress 7.0 added a lighter option for blocks that only need server rendering. According to the PHP-only block registration dev note, you call register_block_type() with a title, attributes, a render_callback and 'supports' => array( 'autoRegister' => true ). The block then appears in the editor with no JavaScript build, and the editor generates sidebar controls automatically for supported attribute types, such as the string, integer, boolean and enum attributes in the dev note's example.
The dev note is clear that this "isn't meant to replace the existing client-side paradigm, nor is it meant to ever be as featureful." It suits classic themes, shortcode replacements and agency projects where a block simply outputs a formatted piece of data. You still need to escape everything inside the render callback. For rich editing (inline RichText, custom toolbars, drag-and-drop), use the standard block.json and React approach.
Adding front-end interactivity
If a block needs behavior on the front end (tabs, toggles, filters, a cart counter), the standard approach since WordPress 6.5 is the Interactivity API. You add "supports": { "interactivity": true } and "viewScriptModule": "file:./view.js" to block.json, add data-wp-* directives to the markup in render.php or save.js, and write a small store in view.js. Scaffold an example with npx @wordpress/create-block@latest my-first-interactive-block --template @wordpress/create-block-interactive-template. Our Interactivity API guide covers directives, state and context in detail.
Plain viewScript files still work for simple cases, but blocks that use the shared standard can communicate with each other and benefit from client-side navigation, and the runtime is loaded once for all of them.
Making blocks play well with patterns, bindings and content-only editing
Modern block editing is increasingly pattern-based, and custom blocks should cooperate with it:
- Mark content attributes. The WordPress 7.0 Field Guide notes that contentOnly mode is applied more broadly, so attributes that represent a block's content should set "role": "content" in block.json to stay editable inside locked patterns.
- Support bindings and overrides. Since WordPress 6.9 you can add your block's attributes to the block_bindings_supported_attributes_{block name} filter, and in WordPress 7.0 that also enables Pattern Overrides for custom blocks. See our guide to block bindings.
- Inherit theme design. Use supports for color, typography and spacing so the block reads presets from the theme's theme.json instead of hard-coding values.
Best practices for production blocks
- One block per folder, one plugin per feature. Keep related blocks in one plugin and register them from a single manifest.
- Keep attributes minimal and typed. Every attribute is a long-term contract stored in post content. Rename or remove attributes only with a deprecation.
- Prefer dynamic rendering when in doubt for anything that pulls data from the database or that you expect to redesign.
- Escape late in PHP. Escape at the moment of output in render.php, following the WordPress coding standards.
- Internationalize from day one. Set textdomain in block.json, wrap strings with __() from @wordpress/i18n in JavaScript and with the PHP translation functions on the server.
- Use the lint scripts. npm run lint:js and lint:css apply WordPress's JavaScript and CSS standards; add PHPCS with WordPressCS for PHP.
- Test in the iframed editor, the Site Editor and on the front end, with a block theme and a classic theme, and with the plugin deactivated to see how saved content degrades.
- Check accessibility. Use real buttons and labels in edit and front-end markup, and keep keyboard focus visible.
- Do not load assets globally. Declare scripts and styles in block.json rather than enqueuing them on every page.
For broader plugin structure (autoloading, settings, data storage), our post on WordPress plugin architecture is a useful companion, and common plugin development mistakes lists problems that apply to block plugins too.
Testing and distributing your block
For local testing, create-block works with wp-env, and the Interactivity API quick start shows npx @wp-playground/cli server --auto-mount, which starts a WordPress Playground site with the plugin mounted. Automated tests can use @wordpress/scripts (Jest for unit tests and Playwright end-to-end utilities), and our plugin testing and QA guide covers a full test plan.
If you publish to WordPress.org, the plugin directory detects block.json files and lists the blocks a plugin provides. To appear in the Block Directory, every block in the plugin must have a block.json file. Run the official Plugin Check plugin before submitting, and use npm run plugin-zip to produce a clean archive.
Conclusion
Custom blocks are no longer exotic: block.json, create-block and @wordpress/scripts give you a standard project in minutes. The decisions that matter are architectural. Pick static or dynamic rendering deliberately, keep attributes stable, use supports instead of custom controls, escape output and build for the iframed editor that WordPress 7.1 now always uses. For simple server-rendered blocks, the PHP-only registration added in 7.0 can save a build step entirely. You can find related guides in our WordPress resource hub, and eSEOspace builds custom blocks and plugins through its WordPress plugin development services.
Frequently asked questions
Do I need React to build a WordPress block?
For a standard block, the editor interface (edit.js) is written in React using WordPress components, but create-block hides the build setup. Since WordPress 7.0, simple server-rendered blocks can be registered in PHP only, with editor controls generated automatically.
Should my block be static or dynamic?
Choose static when the author's content defines the output and the markup is unlikely to change. Choose dynamic when output depends on data that changes (posts, prices, user state) or when you may redesign the markup, because dynamic blocks avoid validation errors and update everywhere at once.
What apiVersion should I use?
Use apiVersion 3, the current version since WordPress 6.3. It signals that the block works inside the iframed editor, which WordPress 7.1 now uses for the post editor regardless of a block's apiVersion.
Can a theme register custom blocks?
It can, but blocks are content, so they usually belong in a plugin. If blocks live in a theme, switching themes leaves broken or invalid blocks in existing posts.
How do I add a second block to an existing block plugin?
Run create-block with the --no-plugin option inside the plugin's source folder to scaffold only the block files. If the plugin registers blocks from a metadata collection, the new block is picked up after the next build.
Put this into action with eSEOspace
We help businesses grow with website design that actually performs. Explore the services behind this guide:
Get a FREE Audit
We'll perform a comprehensive SEO, AEO, GEO & CRO audit of your website — completely free — and show you exactly how to outrank your competitors.
Don't have a site yet? Get in touch →
Get a FREE GEO/AEO/SEO Audit
We'll analyze your site's SEO, GEO, AEO & CRO — completely free — and show you exactly how to get found across Google and AI answers.
Don't have a site yet? Get in touch →
Great — your audit is on the way!
We'll send your free SEO/GEO/AEO/CRO audit within the next few hours. Where should we send it?
You're all set! ✓
Your free audit is being prepared — check your inbox in the next few hours. Talk soon!
On this page
- Key Takeaways
- What a custom block is made of
- Scaffolding a block with @wordpress/create-block
- The block.json fields that matter most
- Static vs dynamic blocks
- PHP-only blocks in WordPress 7.0
- Adding front-end interactivity
- Making blocks play well with patterns, bindings and content-only editing
- Best practices for production blocks
- Testing and distributing your block
- Conclusion
- Frequently asked questions





