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; npx runs a package without installing it globally.

@wordpress/create-block

5.0.0

Scaffolds a plugin (or only the block files) from a template, installs the dependencies and runs a first build.

@wordpress/create-block-interactive-template

3.0.0

External template for blocks that use the Interactivity API.

@wordpress/scripts

37.0.0

wp-scripts: webpack 5.112, Babel, Sass and PostCSS for build/start; ESLint 10, Stylelint 17 and wp-prettier 3.9 for lint-js, lint-style and format; runners for Vitest and Playwright; plugin-zip.

@wordpress/dependency-extraction-webpack-plugin

bundled

Part of the wp-scripts webpack configuration: keeps React and @wordpress/* out of the bundle and writes index.asset.php.

@wordpress/env

12.0.0

wp-env: a WordPress site in Docker with the plugin mounted and activated.

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

@wordpress/create-block 5.0.0

>=22.13.0

Raised in 5.0.0 for stable require(esm).

@wordpress/scripts 37.0.0

^22.22.2 || ^24.15.0 || >=26.0.0

Raised in 36.0.0; an older Node 22 or 24 patch release is refused.

@wordpress/env 12.0.0

>=22.13.0

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

[slug]

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.

--namespace <value>

The part before the / in the block name. The default is create-block, which must always be replaced (lumen here).

--title, --short-description, --category

Copied into block.json and the plugin header.

--textdomain <value>

Text domain of block.json and the PHP strings; defaults to the slug. Blocks of lumen-catalog use lumen-catalog.

-t, --template <name>

standard (default), es5 (no build step, plain ES5), an npm package (@wordpress/create-block-interactive-template) or a local folder.

--variant <name>

A variant defined by the template: static (default) or dynamic for the built-in templates.

--no-plugin

Writes only the block files, into an existing plugin.

--target-dir <directory>

Output folder, defaults to the slug.

--wp-scripts / --no-wp-scripts

Adds (default) or leaves out @wordpress/scripts.

--wp-env

Installs @wordpress/env, adds an env script and writes a .wp-env.json.

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.json declares "supports": { "interactivity": true } and a "viewScriptModule": "file:./view.js" instead of a viewScript, so the front-end code is an ES module loaded through the Script Modules API.

  • render.php prints data-wp-interactive, data-wp-context and other directives, and seeds global state with wp_interactivity_state(); view.js defines the store.

  • The build and start scripts add --experimental-modules, without which viewScriptModule files are not built.

  • package.json lists only @wordpress/interactivity as a dependency; no .editorconfig or .gitignore is 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.0 without 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 es5 produces a block with no build step (plain ES5 on the wp globals), giving up JSX, imports and linting.

  • --no-plugin adds a block to an existing plugin. For lumen-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 no npm install); the next build picks the new block.json up automatically.

  • --wp-env installs @wordpress/env unpinned and writes { "core": "WordPress/WordPress", "plugins": [ "." ] }. WordPress/WordPress is 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

"@wordpress/block-editor": "latest" (and the other dependencies)

Pin exact versions (19.0.0, 17.0.0, 7.0.0, 8.0.0 for interactivity). latest makes every fresh install pick up a new major.

"@wordpress/scripts": "^37.0.0"

Pin 37.0.0, and upgrade deliberately after reading the changelog.

Author: The WordPress Contributors, Requires at least: 6.8, Tested up to: 6.8 in readme.txt

The real author, the plugin’s minimum version (6.9 for lumen-catalog) and the tested version (7.1).

"icon": "smiley"

A Dashicon slug (book, list-view) or an SVG element in index.js.

Red dotted border in editor.scss, blue background in style.scss

Remove or replace; they only show where the block is.

view.js with a console.log and a viewScript field

Delete both when the block needs no front-end script (a static card does not).

"$schema": "https://schemas.wp.org/trunk/block.json"

https://schemas.wp.org/wp/7.1/block.json matches the target version (see block.json and Registration).

@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

build

Production build with webpack: minified, no source maps. Entry points come from the editorScript, script, viewScript (and with --experimental-modules viewScriptModule) fields of every block.json under src/; without any block.json the fallback entry is src/index.js.

start

Development build with source maps, rebuilt on every change. --no-watch builds once; --hot enables React Fast Refresh, which needs SCRIPT_DEBUG and the Gutenberg plugin.

build-blocks-manifest

Writes blocks-manifest.php from the block.json files of a folder (what --blocks-manifest runs after each build).

lint-js

ESLint 10 with @wordpress/eslint-plugin; --fix repairs what it can.

lint-style

Stylelint 17 with @wordpress/stylelint-config for CSS and SCSS.

format

wp-prettier 3.9.6 for JavaScript, TypeScript, JSON and YAML.

test-unit-js

Runs the project’s own Vitest 5 once (since 36.0.0; see below).

test-unit-jest

Maintenance adapter for a project-installed Jest 30.

test-e2e (alias test-playwright)

Playwright end-to-end tests against a running WordPress site.

plugin-zip

Packs the plugin into <name>.zip using the files field of package.json.

packages-update

Updates every @wordpress/* dependency to the latest (or --dist-tag=wp-7.0-style) tag.

check-engines, check-licenses, lint-md-docs, lint-pkg-json

Node.js version, GPL-compatible licences of dependencies, Markdown documentation and package.json checks (the last one is for packages published to npm).

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

@wordpress/blocks, @wordpress/block-editor, any @wordpress/*

wp.blocks, wp.blockEditor

wp-blocks, wp-block-editor

react

React

react

react-dom

ReactDOM

react-dom

react/jsx-runtime (added by the JSX transform)

ReactJSXRuntime

react-jsx-runtime

jquery, lodash, lodash-es, moment

jQuery, lodash, moment

jquery, lodash, moment

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", read index.asset.php next 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 URL http://localhost:8888/wp-content/plugins/lumen-catalog/build/blocks/book-card/index.js, the four handles above and the version f88baaa5451d643529ec.

  • 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 compiled view.js keeps a bare import … 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/blocks limits the scan for block.json to the blocks, and --output-path=build/blocks keeps room in build/ for other entries (the admin screen script built to build/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-modules is needed because lumen/wishlist has a viewScriptModule; it compiles the modules in a second webpack pass. The prebuild script compensates for the cleaning it turns off.

  • The editor packages are dependencies even though they are never bundled: the linter’s import/no-extraneous-dependencies and import/no-unresolved rules check imports against them. At run time the imports resolve to the copies WordPress ships (wp-blocks, wp-block-editor and 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/blocks 15.24.0, @wordpress/block-editor 16.0.0, @wordpress/i18n 6.24.0, @wordpress/interactivity 6.51.0, from its Gutenberg build). wp-scripts packages-update --dist-tag=wp-7.0 installs the versions shipped with a given WordPress major, but npm has no wp-7.1 tag yet; a project that bundles some of these packages pins the 7.1.3 versions instead, as Components, DataViews and Admin Screens does.

  • The files list ships PHP from src/ (the classes) but not the block sources; render.php reaches the ZIP through build/.

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 as wp-content/plugins/lumen-catalog (the folder name) and activates it. The build/ and vendor/ folders must exist before the first start, since the plugin needs both.

  • "testsEnvironment": false stops wp-env 12 from starting a second site on port 8889 (the option is deprecated, and without it a warning is printed).

  • mariadbVersion is pinned because lts now resolves to MariaDB 12.3. SCRIPT_DEBUG is already true in wp-env; it is written out because wp-scripts start --hot depends 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";
	}
}'
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

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

create-block or wp-scripts refuses to run, citing the Node.js version

@wordpress/scripts 37.0.0 needs ^22.22.2 || ^24.15.0 || >=26.0.0; create-block 5.0.0 and wp-env 12 need 22.13+. nvm install 24 and an .nvmrc.

The block does not appear in the inserter

build/ is missing or stale (run npm run build), the PHP registration does not run on init, or the manifest was generated in a different folder from the one passed to wp_register_block_types_from_metadata_collection().

This block contains unexpected or invalid content. after editing save.js

Static block markup changed; existing posts no longer validate. Add a deprecated entry or switch to a dynamic block (see Variations, Styles, Transforms and Deprecations).

Uncaught TypeError: Cannot read properties of undefined on wp.something

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 @wordpress/element bundled (a custom webpack config, --webpack-no-externals, or a bundler other than wp-scripts). Keep dependency extraction on.

viewScriptModule file missing from build/

--experimental-modules absent from the build/start scripts.

Old files (source maps, a deleted block) in the ZIP

--experimental-modules disables output cleaning; delete build/ in a prebuild script.

npm start -- --hot does nothing

Fast Refresh needs SCRIPT_DEBUG and the Gutenberg plugin active on the site.

A custom devServer configuration breaks after upgrading

37.0.0 moved to webpack-dev-server 6: SockJS and the proxy bypass option are gone, middleware runs on Express 5.

wp-env site on port 8889 appears, or tests target another project’s site

"testsEnvironment": false missing; with it, test-e2e in 37.0.0 uses the development site’s port.

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

@wordpress/scripts 37.0.0 needs 22.22.2+, 24.15.0+ or 26+; use the Active or Maintenance LTS release.

npx @wordpress/create-block without a version

Pin it (@5.0.0): the template output changes between releases, and 5.0.0 raised the Node.js floor.

Register each block with register_block_type( __DIR__ . '/build' )

One call to wp_register_block_types_from_metadata_collection() with blocks-manifest.php (6.8; collections 6.7), as generated by create-block 5.0.0.

wp-scripts test-unit-js runs Jest with a built-in preset

Since 36.0.0 it runs the project’s Vitest 5; Jest needs test-unit-jest and a project-installed Jest 30.

wp-scripts test-e2e drives Puppeteer

Playwright since 33.0.0; Puppeteer options are gone.

wp-scripts env or a separate tests site on 8889

wp-scripts env now only prints a deprecation message and exits with status 1; wp-env is its own package (12.0.0), and its tests site is deprecated and disabled with "testsEnvironment": false.

Front-end interactivity with jQuery or a bundled React app in viewScript

The Interactivity API with a viewScriptModule (6.5+), built with --experimental-modules.

apiVersion 1 or 2 and editor CSS enqueued on admin_enqueue_scripts

apiVersion 3; the post editor is always iframed in 7.1, so only block.json styles reach the canvas.

Copy WordPress packages into the bundle, or load React from a CDN

Use the copies WordPress registers; index.asset.php lists them.