Contents
Introduction
This tutorial explains, in exhaustive detail, how to bundle Gutenberg blocks for WordPress using Webpack together with the official @wordpress/scripts package. It covers project layout, installation, single-block and multi-block workflows, handling styles and assets, how to leverage block.json, automatic asset manifest usage, customizing Webpack when you need control, code splitting, internationalization, debugging tips, production optimizations, and common pitfalls.
Why use @wordpress/scripts?
@wordpress/scripts is a zero-configuration build tool created by the WordPress project. It wraps Babel, Webpack, ESLint, PostCSS, and other tools with sensible defaults for building blocks and other editor scripts. Use it to avoid handcrafting a full Webpack config unless you need custom behavior. When your needs outgrow the defaults, you can extend or override the default Webpack configuration.
Prerequisites
- Node.js (LTS recommended) and npm (or Yarn) installed.
- WordPress development install where you can add a plugin for testing.
- Familiarity with ESNext (import/ export), React-style JSX (Gutenberg uses it), and PHP basics for registering the block server-side.
Project layout (recommended)
A recommended plugin layout for one or more blocks is:
my-block-plugin/ ├─ package.json ├─ plugin.php ├─ src/ │ ├─ blocks/ │ │ ├─ hello-world/ │ │ │ ├─ index.js │ │ │ ├─ editor.scss │ │ │ ├─ style.scss │ │ │ └─ block.json │ │ └─ another-block/ │ │ ├─ index.js │ │ ├─ editor.scss │ │ ├─ style.scss │ │ └─ block.json └─ build/ (generated)
Install dependencies
Install @wordpress/scripts and typical build tools (sass for scss compilation if you use SCSS):
npm init -y npm install --save-dev @wordpress/scripts sass
Alternative with Yarn:
yarn init -y yarn add -D @wordpress/scripts sass
package.json scripts
Add convenient scripts. The two most important commands are start (development watch server) and build (production build).
{ name: my-block-plugin, version: 1.0.0, scripts: { start: wp-scripts start, build: wp-scripts build, lint:js: wp-scripts lint-js, format:js: wp-scripts format-js }, devDependencies: { @wordpress/scripts: ^25.0.0, sass: ^1.64.0 } }
Single-block example (simple)
Create a minimal block in src/blocks/hello-world. Key parts:
block.json (metadata)
Use block.json to describe metadata for block registration. This enables registering from the server via register_block_type with the metadata file. The special file:./index.js and file:./style.css notation tells WordPress that the build results should be used (when you register metadata from the build directory).
{ apiVersion: 2, name: my-plugin/hello-world, title: Hello World, category: widgets, icon: smiley, description: A simple hello world block., supports: { html: false }, textdomain: my-plugin, editorScript: file:./index.js, editorStyle: file:./editor.css, style: file:./style.css }
src/blocks/hello-world/index.js (editor entry)
import { registerBlockType } from @wordpress/blocks import { __ } from @wordpress/i18n import { RichText } from @wordpress/block-editor import ./editor.scss import ./style.scss registerBlockType( my-plugin/hello-world, { apiVersion: 2, edit: ( { attributes, setAttributes } ) => { const content = attributes.content Hello world return () }, save: ( { attributes } ) => { return (setAttributes( { content } ) } /> ) }, } )
Styles (SCSS) imports
Import your editor and front-end styles directly from JS. @wordpress/scripts will extract and emit CSS files for the corresponding JS entry.
/ src/blocks/hello-world/style.scss / .my-plugin-hello { padding: 10px background: #f0f0f0 } / src/blocks/hello-world/editor.scss / .my-plugin-hello { border: 1px dashed #ccc }
Build and register
Run a build and then register the block on the PHP side. After running npm run build, a build folder will be generated with compiled JS and CSS and small asset PHP files that contain dependencies and version info.
npm run build
PHP registration (recommended: use block.json metadata)
Two common approaches: (1) register script and style manually using the generated asset file, or (2) use register_block_type reading the block.json file from the build folder. Both examples are shown below.
// plugin.php (manual registration using assets generated by wp-scripts) function myplugin_register_hello_block() { dir = plugin_dir_path( __FILE__ ) . build/hello-world // Each built entry will have an asset.php with dependencies and version asset_file = include( dir . /index.asset.php ) wp_register_script( myplugin-hello-editor, plugins_url( build/hello-world/index.js, __FILE__ ), asset_file[dependencies], asset_file[version] ) // If CSS was emitted: wp_register_style( myplugin-hello-style, plugins_url( build/hello-world/style.css, __FILE__ ), array(), filemtime( dir . /style.css ) ) register_block_type( dir . /block.json, array( editor_script => myplugin-hello-editor, style => myplugin-hello-style ) ) } add_action( init, myplugin_register_hello_block )
PHP registration (automatic via metadata)
If you place the built block.json in a folder in your plugin and keep the file:./index.js notation, you can call register_block_type with the metadata file path – WordPress will lookup and enqueue the referenced files. This is simpler when your build output keeps the block.json next to the built assets.
// plugin.php (metadata-based registration) function myplugin_register_blocks_from_build() { register_block_type( __DIR__ . /build/hello-world/block.json ) } add_action( init, myplugin_register_blocks_from_build )
Working with multiple blocks in one plugin
Two main strategies:
- Keep each block as its own folder under src/blocks/ and run wp-scripts build with a small custom step that copies built block folders to plugin build/ location. Use block.json for each block. This aligns with block metadata and recommended registration.
- Use a custom Webpack entry map so that a single build produces multiple named entries (one output JS and CSS file per block). To do that you will extend the default Webpack config instructions follow in the Customize Webpack section.
How @wordpress/scripts treats WordPress packages
By default, @wordpress/scripts externalizes WordPress provided packages (packages under @wordpress/) so they are not bundled. They are expected to be available globally on the WordPress page via the wp global (e.g., wp.blocks, wp.element, wp.i18n). This keeps bundles small and avoids duplicating Gutenberg code. If you need to bundle a third-party dependency that is not present in WordPress, just import it it will be bundled unless you explicitly mark it external.
Asset files produced by build
For each entry, wp-scripts produces:
- Compiled JavaScript file (e.g., index.js)
- Compiled CSS files for editor and front-end (if you imported .scss/.css)
- An asset manifest PHP file (e.g., index.asset.php) containing an array with dependencies and version — use this to register/enqueue your script with correct dependencies and cache-busting version.
- When using block.json in the build folder, those files will be discovered when you call register_block_type on the metadata file.
Customizing Webpack when you need it
If you need to add a loader, change entry names, or implement special output behavior (multiple entry points), extend the default @wordpress/scripts Webpack configuration rather than replacing it. The default config is exported by @wordpress/scripts and can be required and modified.
Example: add image handling and multiple entries
Create a top-level webpack.config.js that imports the default factory and returns a modified config. The default is a function that accepts (env, argv) — call it and mutate the result.
// webpack.config.js const path = require( path ) const defaultConfigFactory = require( @wordpress/scripts/config/webpack.config ) module.exports = ( env, argv ) => { const config = defaultConfigFactory( env, argv ) // Example: multiple entries for two blocks config.entry = { hello-world: path.resolve( __dirname, src/blocks/hello-world/index.js ), another-block: path.resolve( __dirname, src/blocks/another-block/index.js ) } // Example: asset/resource rule for images (emits to build/) config.module.rules.push( { test: /.(pngjpe?ggifsvg)/, type: asset/resource, generator: { filename: images/[name][hash][ext] } } ) // Optionally modify output filename pattern (be careful with block.json references) config.output = Object.assign( {}, config.output, { filename: [name].js } ) return config }
Notes:
- If you change filenames or folder structure, ensure your PHP registration points to the right files or your block.json references the filenames correctly.
- When adding new loaders, keep the default rules and just append new ones where needed.
How to use the generated index.asset.php file
The generated PHP asset file contains the JS dependencies and a version hash. Use it in your PHP registration so that WordPress enqueues your script with correct dependencies.
// Example using generated asset file for a build entry hello-world function myplugin_register_hello_block_with_asset() { base = plugin_dir_path( __FILE__ ) . build/hello-world/ asset = include base . index.asset.php wp_register_script( myplugin-hello-editor, plugins_url( build/hello-world/index.js, __FILE__ ), asset[dependencies], asset[version], true ) register_block_type( base . block.json ) } add_action( init, myplugin_register_hello_block_with_asset )
Development: using wp-scripts start
Run npm run start in your project root. This spins up a Webpack dev server in watch mode and rebuilds on change. It uses the same configuration as build but with watch friendly options. For many block projects this is sufficient: edit files, and refresh the Gutenberg editor in the browser to see updates. You can also enable hot module replacement if you set up the dev server for your theme/plugin, but that requires additional Webpack customization.
i18n (translations) in JavaScript code
Use @wordpress/i18n functions (__, _n, etc.) in your JavaScript. During build, @wordpress/scripts will transform translation function calls if you additionally run a workflow to extract strings (wp i18n make-pot or other tooling). A common process:
- Write code using __() from @wordpress/i18n.
- Extract strings to a POT file (e.g., using WP-CLI make-pot or other extractor).
- Create .po/.mo files for the plugin domain used in PHP’s load_plugin_textdomain or via the plugin headers.
Code splitting and dynamic imports
Use dynamic imports to split heavy dependencies into separate chunks that load only when needed. Example:
// Lazy load a component for performance const LazyComponent = React.lazy( () => import( ./heavy-editor-component ) ) function Edit() { return (Loading…