Block Development Environment
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), which needs PHP 7.4 at least and recommends PHP 8.3 or later, with
@wordpress/create-block 5.0.0, @wordpress/create-block-interactive-template 3.0.0,
@wordpress/scripts 37.0.0 and @wordpress/env 12.0.0 (all released in October 2026), running on Node.js 24.20.0 with
npm 11.19.0. WordPress 7.2 is in beta and due in December 2026; nothing on this page depends on it. Every command and
output below was run on 2026-10-28, the date the page was checked.
A block is built from JavaScript (the editor side, written with React and JSX), optional PHP (server rendering) and
styles, described by a block.json file. Browsers cannot run JSX or the import statements of the editor packages
directly, so block code goes through a build step. WordPress ships the tools for that step as npm packages: create-block
generates a working plugin, @wordpress/scripts (the wp-scripts command) builds, lints, formats, tests and packages
it with no configuration, and wp-env runs a WordPress site in Docker that loads the plugin. This page is for plugin
and block developers; it builds the toolchain of the Lumen Press lumen-catalog plugin, which holds the
lumen/book-card (static), lumen/latest-books (dynamic) and lumen/wishlist (Interactivity API) blocks. What goes
inside block.json and how blocks are registered on the server is the subject of
block.json and Registration; setting up wp-env, Docker Compose and
Playground in general is covered in
Local Development Environments.
The toolchain
| Tool | Version used | Role |
|---|---|---|
Node.js and npm |
24.20.0, 11.19.0 |
Run every tool below. npm installs packages; |
|
5.0.0 |
Scaffolds a plugin (or only the block files) from a template, installs the dependencies and runs a first build. |
|
3.0.0 |
External template for blocks that use the Interactivity API. |
|
37.0.0 |
|
|
bundled |
Part of the |
|
12.0.0 |
|
The packages are released together from the Gutenberg monorepo; the Gutenberg plugin (24.2.0) is not needed to build
blocks. @wordpress/scripts went from the 30.x line used with WordPress 6.9 to 37.0.0 within 2026, with the breaking
changes listed in Corrections to older books and tutorials.
Node.js and npm
The Block Editor Handbook recommends the Node.js Active LTS release, installed with a version manager such as nvm so
that each project can select the version it was built with. The current packages accept these ranges:
| Package | engines.node |
Effect |
|---|---|---|
|
|
Raised in 5.0.0 for stable |
|
|
Raised in 36.0.0; an older Node 22 or 24 patch release is refused. |
|
|
Also needs Docker. |
Node.js 22 has been a Maintenance LTS release since 2025-10-21 and Node.js 24 since 2026-10-20; Node.js 26 is scheduled
to become Active LTS on 2026-10-28. Node.js 24 was used for this page and is the safer choice for a team today, since all
three packages are tested with it. With nvm:
nvm install 24 # installs the latest 24.x release
nvm use 24
node -v # v24.20.0
npm -v # 11.19.0
echo "24" > .nvmrc # "nvm use" in the project folder then selects Node.js 24
An engines field in the plugin’s package.json documents the range for everyone else, and wp-scripts check-engines
enforces it (since 36.0.0 it checks only the tools listed in engines, and only Node.js when the field is absent).
npm 11.19 blocks the install scripts of dependencies by default: an install ends with a list of packages whose
install/postinstall scripts were skipped, and npm install-scripts approve <pkg> records an exception in the
allowScripts field of package.json. The @wordpress/scripts 37.0.0 tree has five such packages (core-js,
core-js-pure, fs-ext-extra-prebuilt, unrs-resolver and @parcel/watcher); every command on this page worked
without approving any of them, so there is no need to approve them blindly.
Scaffolding blocks with create-block
create-block is run with npx, pinned to a version so the result is reproducible. Given a slug it runs in quick
mode: the slug becomes the folder name, the plugin name and the block’s name after the namespace. The options:
| Option | Meaning |
|---|---|
|
Block slug, output folder and plugin name. Without it the tool runs in interactive mode and asks for each value (slug, namespace, title, description, icon, category, plugin header fields) in the terminal. |
|
The part before the |
|
Copied into |
|
Text domain of |
|
|
|
A variant defined by the template: |
|
Writes only the block files, into an existing plugin. |
|
Output folder, defaults to the slug. |
|
Adds (default) or leaves out |
|
Installs |
A static block
npx @wordpress/create-block@5.0.0 book-card \
--namespace lumen \
--title "Book Card" \
--short-description "A card for one Lumen Press book." \
--category widgets \
--textdomain lumen-catalog
The command took about 80 seconds: it writes block.json and package.json, installs the editor packages and
@wordpress/scripts (about 1,700 npm packages), formats the JavaScript and runs a first build with a blocks manifest.
Leaving out node_modules, the result is:
book-card/
├── .editorconfig # tabs, LF, UTF-8, as in the WordPress coding standards
├── .gitignore # node_modules, logs, *.zip; build/ is NOT ignored
├── book-card.php # plugin header and block registration
├── package.json
├── package-lock.json
├── readme.txt # Plugin Directory readme
├── src/
│ └── book-card/
│ ├── block.json # metadata; the asset fields point at the built files
│ ├── index.js # registerBlockType( metadata.name, { edit, save } )
│ ├── edit.js # editor component (useBlockProps)
│ ├── save.js # markup serialized into post_content
│ ├── editor.scss # editor-only styles -> build/book-card/index.css
│ ├── style.scss # front end and editor -> build/book-card/style-index.css
│ └── view.js # front-end script (viewScript)
└── build/
├── blocks-manifest.php # every block.json of build/ as one PHP array
└── book-card/
├── block.json
├── index.js # 1,249 bytes, minified
├── index.asset.php # dependencies and version of index.js
├── index.css index-rtl.css
├── style-index.css style-index-rtl.css
├── view.js
└── view.asset.php
Each SCSS file imported by a JavaScript entry is extracted to a CSS file named after the entry (files whose name
contains style go to style-index.css), and an RTL copy is generated for right-to-left languages. The main plugin
file registers everything in build/ from the manifest:
<?php
/**
* Plugin Name: Book Card
* Requires at least: 6.8
* Requires PHP: 7.4
* Author: The WordPress Contributors
* Text Domain: lumen-catalog
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
function lumen_book_card_block_init() {
wp_register_block_types_from_metadata_collection( __DIR__ . '/build', __DIR__ . '/build/blocks-manifest.php' );
}
add_action( 'init', 'lumen_book_card_block_init' );
create-block 5.0.0 targets WordPress 6.8, the version that added wp_register_block_types_from_metadata_collection()
(6.7 added the collection itself, wp_register_block_metadata_collection()). Older templates generated one register_block_type( __DIR__ . '/build/<slug>' ) call per block.
The dynamic variant
npx @wordpress/create-block@5.0.0 latest-books --variant dynamic \
--namespace lumen \
--title "Latest Books" \
--short-description "The most recent Lumen Press books." \
--category widgets \
--textdomain lumen-catalog
The tree is the same except that save.js is replaced by render.php, block.json gains
"render": "file:./render.php", and index.js registers only edit. The build copies render.php to
build/latest-books/ because block.json names it (other PHP files in src/ are copied only with
--webpack-copy-php). The generated render.php prints the block wrapper and a translated string:
<p <?php echo get_block_wrapper_attributes(); ?>>
<?php esc_html_e( 'Latest Books – hello from a dynamic block!', 'lumen-catalog' ); ?>
</p>
The query of books that replaces it is written in Static and Dynamic Blocks.
The interactive template
npx @wordpress/create-block@5.0.0 wishlist \
--template @wordpress/create-block-interactive-template@3.0.0 \
--namespace lumen \
--title "Wishlist" \
--short-description "Lets readers keep a wishlist of books." \
--category widgets \
--textdomain lumen-catalog
The template (which needs WordPress 6.5 or later) has three variants: default, typescript (a typed view.ts) and
client-side-navigation (adds @wordpress/interactivity-router). The default variant differs from the standard
template in four places:
-
block.jsondeclares"supports": { "interactivity": true }and a"viewScriptModule": "file:./view.js"instead of aviewScript, so the front-end code is an ES module loaded through the Script Modules API. -
render.phpprintsdata-wp-interactive,data-wp-contextand other directives, and seeds global state withwp_interactivity_state();view.jsdefines the store. -
The
buildandstartscripts add--experimental-modules, without whichviewScriptModulefiles are not built. -
package.jsonlists only@wordpress/interactivityas a dependency; no.editorconfigor.gitignoreis generated.
Two consequences show up at once. view.asset.php declares the module type and a module dependency, not a script
handle:
<?php return array(
'dependencies' => array(
'@wordpress/interactivity'
),
'version' => '72df2b2ef1ccc3dabd89',
'type' => 'module'
);
And the freshly scaffolded project does not pass its own linter: npm run lint:js reports six errors and one warning.
edit.js and index.js import @wordpress/i18n, @wordpress/block-editor and @wordpress/blocks without listing them
in package.json (import/no-extraneous-dependencies, import/no-unresolved), edit.js destructures attributes and
setAttributes without using them (no-unused-vars), and view.js logs to the console (no-console). The fixes are to add the three packages to dependencies, drop the unused parameters and remove the console.log
callback (and its data-wp-watch directive in render.php). The store itself is rewritten in
Interactivity API.
Other ways to run it
-
npx @wordpress/create-block@5.0.0without a slug starts the interactive mode, which asks for the same values one by one; it suits a first block, while scripts and documentation use quick mode for reproducibility. -
--template es5produces a block with no build step (plain ES5 on thewpglobals), giving up JSX, imports and linting. -
--no-pluginadds a block to an existing plugin. Forlumen-catalog, run from the plugin root:npx @wordpress/create-block@5.0.0 local-bookshop --no-plugin --namespace lumen --title "Local Bookshop" --textdomain lumen-catalog --target-dir src/blocks/local-bookshop. It writes only the seven block files (in about two seconds, with nonpm install); the next build picks the newblock.jsonup automatically. -
--wp-envinstalls@wordpress/envunpinned and writes{ "core": "WordPress/WordPress", "plugins": [ "." ] }.WordPress/WordPressis the GitHub mirror’s default branch, the development version of WordPress rather than a release, and the file has no"testsEnvironment": false; replace it with the file shown in Loading the plugin in the editor with wp-env.
What to change after scaffolding
The templates are deliberately generic. Before the first commit:
| Generated | Change for a real plugin |
|---|---|
|
Pin exact versions ( |
|
Pin |
|
The real author, the plugin’s minimum version ( |
|
A Dashicon slug ( |
Red dotted border in |
Remove or replace; they only show where the block is. |
|
Delete both when the block needs no front-end script (a static card does not). |
|
|
@wordpress/scripts commands
wp-scripts is meant to be called from the scripts section of package.json (or with npx wp-scripts <command>).
The commands in version 37.0.0:
| Command | What it does |
|---|---|
|
Production build with webpack: minified, no source maps. Entry points come from the |
|
Development build with source maps, rebuilt on every change. |
|
Writes |
|
ESLint 10 with |
|
Stylelint 17 with |
|
wp-prettier 3.9.6 for JavaScript, TypeScript, JSON and YAML. |
|
Runs the project’s own Vitest 5 once (since 36.0.0; see below). |
|
Maintenance adapter for a project-installed Jest 30. |
|
Playwright end-to-end tests against a running WordPress site. |
|
Packs the plugin into |
|
Updates every |
|
Node.js version, GPL-compatible licences of dependencies, Markdown documentation and |
Common options of build and start: --source-path (default src), --output-path (default build),
--blocks-manifest, --experimental-modules, --webpack-copy-php, --webpack-no-externals and
--webpack-bundle-analyzer. A webpack.config.js at the project root replaces the default configuration; extending
it means spreading require( '@wordpress/scripts/config/webpack.config' ) and still running wp-scripts, never
webpack directly.
build and start
In the lumen-catalog project below, npm run build compiles the three blocks in two webpack passes (scripts, then
modules) in about two seconds; book-card/index.js is 1,253 bytes. A one-off start build of the same block writes a
15,586-byte unminified index.js plus index.js.map, index.css.map and style-index.css.map, and a different
version hash in index.asset.php, which is how WordPress busts the browser cache after every rebuild.
With --experimental-modules, the two webpack passes share the output folder, so wp-scripts turns off webpack’s
output.clean: files from an earlier start (source maps) or from a deleted block stay in build/ and end up in the
release ZIP. A prebuild script that deletes the output folder (shown in the package.json below) avoids it.
Without that flag, build empties the output folder itself, keeping only fonts/ and images/.
lint-js, lint-style and format
The linters exit with a non-zero status when they find errors, so they can gate CI. An SCSS rule written on one line
with #FFF and no trailing semicolon got four @stylistic/* errors from lint-style (brace newlines, hex case,
trailing semicolon), all reported as fixable with --fix.
Since 36.0.0, lint-style finds its configuration with Stylelint’s own resolution, which also searches parent folders
and ~/.config/stylelint; a stray configuration there silently replaces the bundled default. format rewrites files
in place, and its output lists each file with (unchanged) when nothing had to change. Neither lint-js nor format
touch PHP: the PHP side is checked with WordPress Coding Standards (see
Coding Standards and Static Analysis).
test-unit-js
@wordpress/scripts 36.0.0 replaced its bundled Jest with the project’s own Vitest. Without Vitest installed the
command runs no tests and prints an instruction instead: install vitest@^5, and vite@^8 for a new setup, or switch
the script to wp-scripts test-unit-jest to keep a Jest suite.
lumen-catalog pins vitest 5.0.5 and vite 8.3.5. Vitest discovers *.test.* and *.spec.* files, runs in
Node.js by default (no DOM), and its APIs are imported explicitly. A helper of the book card and its test:
// src/blocks/book-card/format-isbn.js
/**
* Hyphenates a 13-digit ISBN for display; other input is returned unchanged.
*
* @param {string} isbn Raw ISBN, with or without hyphens.
* @return {string} The formatted ISBN.
*/
export function formatIsbn( isbn ) {
const digits = String( isbn ).replace( /[^0-9]/g, '' );
if ( digits.length !== 13 ) {
return isbn;
}
return [
digits.slice( 0, 3 ),
digits.slice( 3, 4 ),
digits.slice( 4, 6 ),
digits.slice( 6, 12 ),
digits.slice( 12 ),
].join( '-' );
}
// src/blocks/book-card/format-isbn.test.js
import { describe, expect, it } from 'vitest';
import { formatIsbn } from './format-isbn';
describe( 'formatIsbn', () => {
it( 'hyphenates a 13-digit ISBN', () => {
expect( formatIsbn( '9780000000002' ) ).toBe( '978-0-00-000000-2' );
} );
it( 'returns other input unchanged', () => {
expect( formatIsbn( '12345' ) ).toBe( '12345' );
} );
} );
$ npm run test:unit
> lumen-catalog@0.1.0 test:unit
> wp-scripts test-unit-js
RUN v5.0.5 lumen-catalog
Test Files 1 passed (1)
Tests 2 passed (2)
Test files are not entry points (only block.json fields are), so they never reach build/. Component tests that render
React need a jsdom or browser environment configured in vitest.config.mjs; wp-scripts supplies no preset, as the
migration guide explains. Projects with an existing Jest suite switch their script to wp-scripts test-unit-jest and
install jest 30 and the published @wordpress/jest-preset-default themselves.
test-e2e
test-e2e runs Playwright (Puppeteer was removed in 33.0.0) with the helpers of
@wordpress/e2e-test-utils-playwright; tests live in specs/ with a .spec.js or .test.js suffix, and failures leave
screenshots in artifacts/. The site under test is WP_BASE_URL, or the wp-env site configured in the project folder.
Since 37.0.0 a .wp-env.json with "testsEnvironment": false makes the runner use the development site’s port instead
of falling back to http://localhost:8889, and --wp-env-config=.wp-env.test.json points it at an environment
started from another file. Before each run the command installs the browsers with the CLI of the bundled
@playwright/test (so they match the runner), unless PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD is set, as it usually is in a CI
image that already has them. The end-to-end layer for Lumen Press is written in
Testing; the general Playwright material is in
E2E Testing in the Browser.
plugin-zip
plugin-zip packs the files that npm pack would (it uses npm-packlist), under a root folder named after the
package, so WordPress can update the plugin from the ZIP. Without a files field it follows the Plugin Handbook’s
file-organisation rules; with one, only the listed paths go in, plus package.json, which npm always includes. For
lumen-catalog the ZIP holds 43 files (54,641 bytes): the main file, readme.txt, src/Plugin.php, src/Blocks.php,
the Composer autoloader in vendor/ and the whole build/blocks/ tree, and none of src/blocks/, node_modules or
the tests. Run composer install --no-dev --optimize-autoloader and npm run build first; plugin-zip builds
nothing. Release packaging is covered in
Publishing to the Plugin Directory.
index.asset.php and dependency extraction
WordPress already loads React, ReactDOM and every @wordpress/* package in the editor as registered scripts (react,
wp-blocks, wp-block-editor …). A block must use those copies: a second React in the page breaks hooks and context,
and bundling @wordpress/block-editor would duplicate megabytes of code that is already there. The dependency
extraction plugin in the wp-scripts webpack configuration therefore turns each import of a WordPress-provided package
into a reference to a global, and records the script handle it needs:
| Import | Global used in the bundle | Script handle in index.asset.php |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The minified book-card/index.js starts like this; no React or editor code is inside it:
(()=>{"use strict";var o={936(){const o=window.wp.blocks,e=window.wp.i18n,n=window.wp.blockEditor,r=window.ReactJSXRuntime, …
Next to it, index.asset.php returns the handles and a content hash:
<?php return array(
'dependencies' => array(
'react-jsx-runtime',
'wp-block-editor',
'wp-blocks',
'wp-i18n'
),
'version' => 'f88baaa5451d643529ec'
);
The list follows the imports. Adding import { useState } from 'react'; and
import { Button } from '@wordpress/components'; to edit.js and rebuilding adds react and wp-components to the
array, while the bundle grows only from 1,249 to 1,285 bytes. Building the same file with --webpack-no-externals
(which turns the extraction off) produced a 4,314,995-byte index.js and no asset file at all: React, the components
and the block editor copied into the plugin.
How WordPress uses the file:
-
For a block, nothing has to be written.
register_block_type()(and the metadata-collection functions) see"editorScript": "file:./index.js", readindex.asset.phpnext to it, and register a script handle (lumen-book-card-editor-script) with those dependencies and that version. On the test site below,wp_scripts()->query( 'lumen-book-card-editor-script' )returned the URLhttp://localhost:8888/wp-content/plugins/lumen-catalog/build/blocks/book-card/index.js, the four handles above and the versionf88baaa5451d643529ec. -
For a script that is not a block (an admin screen, a sidebar plugin), the plugin reads the file itself:
$asset = require plugin_dir_path( LUMEN_CATALOG_FILE ) . 'build/admin/catalog-screen.asset.php'; wp_enqueue_script( 'lumen-catalog-screen', plugins_url( 'build/admin/catalog-screen.js', LUMEN_CATALOG_FILE ), $asset['dependencies'], $asset['version'], array( 'in_footer' => true ) ); -
For a script module (
viewScriptModule), the asset file has'type' => 'module'and module identifiers such as@wordpress/interactivity. WordPress registers the module with the Script Modules API and prints an import map ({"imports":{"@wordpress/interactivity":"…/wp-includes/js/dist/script-modules/interactivity/index.js?ver=…"}}), so the compiledview.jskeeps a bareimport … from "@wordpress/interactivity".
Hard-coding the dependency array or the version instead of reading the asset file is the classic mistake: a new import
then fails with wp.components is undefined at runtime, and a fixed version keeps serving stale code from browser
caches.
A multi-block plugin: lumen-catalog
create-block generates one plugin per block. Lumen Press keeps all its blocks in lumen-catalog, the plugin that
also owns the lp_book post type, so the three scaffolds are moved under src/blocks/ and built by one
package.json. src/ also holds the namespaced PHP classes autoloaded by Composer (see
Plugin Fundamentals):
lumen-catalog/
├── lumen-catalog.php
├── composer.json
├── package.json
├── package-lock.json
├── .wp-env.json
├── .editorconfig
├── .gitignore
├── readme.txt
├── src/
│ ├── Plugin.php
│ ├── Blocks.php # registers build/blocks/ from its manifest
│ └── blocks/
│ ├── book-card/ # block.json, index.js, edit.js, save.js, *.scss, format-isbn(.test).js
│ ├── latest-books/ # block.json, index.js, edit.js, render.php, *.scss
│ └── wishlist/ # block.json, index.js, edit.js, render.php, view.js, *.scss
├── build/
│ └── blocks/
│ ├── blocks-manifest.php
│ ├── book-card/
│ ├── latest-books/
│ └── wishlist/
└── vendor/
{
"name": "lumen-catalog",
"version": "0.1.0",
"private": true,
"description": "Book catalogue for Lumen Press.",
"license": "GPL-2.0-or-later",
"engines": {
"node": "^22.22.2 || ^24.15.0"
},
"files": [
"lumen-catalog.php",
"uninstall.php",
"readme.txt",
"build",
"languages",
"src/*.php",
"vendor"
],
"scripts": {
"prebuild": "node -e \"require('fs').rmSync('build/blocks',{recursive:true,force:true})\"",
"build": "wp-scripts build --source-path=src/blocks --output-path=build/blocks --experimental-modules --blocks-manifest",
"start": "wp-scripts start --source-path=src/blocks --output-path=build/blocks --experimental-modules --blocks-manifest",
"format": "wp-scripts format src/blocks",
"lint:js": "wp-scripts lint-js src/blocks",
"lint:css": "wp-scripts lint-style \"src/blocks/**/*.scss\"",
"test:unit": "wp-scripts test-unit-js",
"test:e2e": "wp-scripts test-e2e",
"check-engines": "wp-scripts check-engines",
"plugin-zip": "wp-scripts plugin-zip",
"env": "wp-env"
},
"dependencies": {
"@wordpress/block-editor": "19.0.0",
"@wordpress/blocks": "17.0.0",
"@wordpress/i18n": "7.0.0",
"@wordpress/interactivity": "8.0.0"
},
"devDependencies": {
"@wordpress/env": "12.0.0",
"@wordpress/scripts": "37.0.0",
"vite": "8.3.5",
"vitest": "5.0.5"
}
}
-
--source-path=src/blockslimits the scan forblock.jsonto the blocks, and--output-path=build/blockskeeps room inbuild/for other entries (the admin screen script built tobuild/admin/in Internationalization). The manifest is written into the output folder,build/blocks/blocks-manifest.php, keyed by block folder (book-card,latest-books,wishlist). -
--experimental-modulesis needed becauselumen/wishlisthas aviewScriptModule; it compiles the modules in a second webpack pass. Theprebuildscript compensates for the cleaning it turns off. -
The editor packages are
dependencieseven though they are never bundled: the linter’simport/no-extraneous-dependenciesandimport/no-unresolvedrules check imports against them. At run time the imports resolve to the copies WordPress ships (wp-blocks,wp-block-editorand so on), so the pinned npm versions serve the linter and the editor’s autocompletion only and say nothing about the WordPress version. They are the npm releases current on 2026-10-28, whose majors run ahead of the copies in WordPress 7.1.3 (@wordpress/blocks15.24.0,@wordpress/block-editor16.0.0,@wordpress/i18n6.24.0,@wordpress/interactivity6.51.0, from its Gutenberg build).wp-scripts packages-update --dist-tag=wp-7.0installs the versions shipped with a given WordPress major, but npm has nowp-7.1tag yet; a project that bundles some of these packages pins the 7.1.3 versions instead, as Components, DataViews and Admin Screens does. -
The
fileslist ships PHP fromsrc/(the classes) but not the block sources;render.phpreaches the ZIP throughbuild/.
The build writes build/blocks/blocks-manifest.php and one folder per block with the same files create-block
produced: block.json, index.js, index.asset.php and the four CSS files for each block, render.php for
latest-books and wishlist, and view.js with view.asset.php for wishlist.
Lumen\Catalog\Blocks passes build/blocks and its manifest to wp_register_block_types_from_metadata_collection() on
init; the class and the errors it can raise are in
block.json and Registration. The scaffold’s .gitignore does not
ignore build/. Lumen Press ignores it and builds in CI (see
Deployment and CI/CD); a plugin installed straight from a Git checkout
has to commit it instead, or the blocks are not registered.
lint:js, lint:css, format and test:unit all passed on this project after the fixes listed for the interactive
template, and php -l found no syntax errors in the PHP files, blocks-manifest.php and the asset files.
Loading the plugin in the editor with wp-env
wp-env mounts the plugin folder into a WordPress container and activates it, so npm start in one terminal and the
editor in a browser give an edit-save-reload loop with no copying. The plugin’s own .wp-env.json:
{
"$schema": "https://schemas.wp.org/trunk/wp-env.json",
"core": "https://wordpress.org/wordpress-7.1.3.zip",
"phpVersion": "8.4",
"mariadbVersion": "11.8",
"testsEnvironment": false,
"port": 8888,
"plugins": [ "." ],
"config": {
"SCRIPT_DEBUG": true
}
}
-
"plugins": [ "." ]mounts the current folder aswp-content/plugins/lumen-catalog(the folder name) and activates it. Thebuild/andvendor/folders must exist before the first start, since the plugin needs both. -
"testsEnvironment": falsestops wp-env 12 from starting a second site on port 8889 (the option is deprecated, and without it a warning is printed). -
mariadbVersionis pinned becauseltsnow resolves to MariaDB 12.3.SCRIPT_DEBUGis alreadytruein wp-env; it is written out becausewp-scripts start --hotdepends on it, and makes WordPress load the unminified core scripts, which gives readable React errors.
The full option list, the override file and the other runtimes are in Local Development Environments. The working session:
npm run build # build/ must exist before WordPress loads the plugin
npm run env start # http://localhost:8888, admin / password
npx wp-env run cli wp plugin list --fields=name,status,version # npm run would need -- before --fields
npm start # second terminal: rebuild on every change
npm run env stop # free the ports; content is kept
npm run env destroy -- --force # remove containers, volumes and images
The first start took 78 seconds (image builds and the WordPress download); wp core version then reported 7.1.3 and
wp plugin list showed lumen-catalog active at version 0.1.0, next to the inactive Akismet and Hello Dolly.
A WP-CLI one-liner lists what was registered from the manifest, with the asset handles WordPress derived from
block.json:
npx wp-env run cli wp eval '
foreach ( WP_Block_Type_Registry::get_instance()->get_all_registered() as $name => $type ) {
if ( 0 === strpos( $name, "lumen/" ) ) {
echo $name, " editor=", implode( ",", $type->editor_script_handles ),
" view_modules=", implode( ",", $type->view_script_module_ids ),
" render=", $type->render_callback ? "yes" : "no", "\n";
}
}'
Source: WP-CLI Commands: wp eval
lumen/book-card editor=lumen-book-card-editor-script view_modules= render=no
lumen/latest-books editor=lumen-latest-books-editor-script view_modules= render=yes
lumen/wishlist editor=lumen-wishlist-editor-script view_modules=lumen-wishlist-view-script-module render=yes
Source: Code Reference: WP_Block_Type
The blocks are offered in the inserter’s Widgets category (the category of their block.json) in the post editor at
http://localhost:8888/wp-admin/post-new.php. This is the plugin as scaffolded;
block.json and Registration then moves the blocks to the plugin’s
own lumen-catalog category and gives lumen/latest-books a view.js module that formats its dates, so from there on
its view_modules is no longer empty.
A page containing all three, created with wp post create and fetched with curl, showed what reaches the front end:
the three style-index.css files inlined as <style id="lumen-…-style-inline-css"> (WordPress inlines small block
styles), no script for the two blocks without front-end code, the @wordpress/interactivity import map and a
<script type="module"> for wishlist/view.js, and the global state from wp_interactivity_state() as a JSON
<script> element. Block assets are enqueued only on pages that contain the block.
With npm start running, saving edit.js triggers an incremental rebuild (a one-off development build of the three
blocks took under a second) and a browser reload of the editor shows the change. Changes to block.json regenerate the
manifest, and an edited render.php reaches WordPress only once the watcher (or a build) has copied it to build/,
because WordPress reads the copy. Because the post editor is always iframed in 7.1, editor styles must be
declared in block.json (editorStyle, style) rather than enqueued globally on admin_enqueue_scripts, which no
longer reaches the content inside the iframe.
Errors and pitfalls
| Symptom | Cause and fix |
|---|---|
|
|
The block does not appear in the inserter |
|
|
Static block markup changed; existing posts no longer validate. Add a |
|
A script enqueued by hand without the asset file’s dependency list, so the WordPress package loads after the block script. |
"Invalid hook call" or two Reacts in the page |
React or |
|
|
Old files (source maps, a deleted block) in the ZIP |
|
|
Fast Refresh needs |
A custom |
37.0.0 moved to webpack-dev-server 6: SockJS and the proxy |
wp-env site on port 8889 appears, or tests target another project’s site |
|
Corrections to older books and tutorials
None of the three older books covers block development: the site-owner books stop at using the editor, and the developer-oriented one teaches classic themes and PHP plugins only, so readers coming from them have no build toolchain at all. Tutorials of the WordPress 5.x and 6.x era contain steps that no longer hold:
| Older advice | Current state |
|---|---|
Install Node.js 14, 16 or 18 |
|
|
Pin it ( |
Register each block with |
One call to |
|
Since 36.0.0 it runs the project’s Vitest 5; Jest needs |
|
Playwright since 33.0.0; Puppeteer options are gone. |
|
|
Front-end interactivity with jQuery or a bundled React app in |
The Interactivity API with a |
|
|
Copy WordPress packages into the bundle, or load React from a CDN |
Use the copies WordPress registers; |
References
-
Block Editor Handbook: Working with JavaScript for the Block Editor
-
Block Editor Handbook: Static or Dynamic rendering of a block
-
Block Editor Handbook: @wordpress/create-block-interactive-template
-
Block Editor Handbook: @wordpress/dependency-extraction-webpack-plugin
-
Gutenberg repository: @wordpress/scripts Vitest migration guide
-
Code Reference: wp_register_block_types_from_metadata_collection()
-
Make WordPress Core: More efficient block type registration in 6.8