How to create Gutenberg blocks with @wordpress/scripts (JS) in WordPress

Contents

Introduction

This tutorial explains, in complete detail, how to create Gutenberg blocks for WordPress using the official @wordpress/scripts toolchain. It covers project setup, block metadata, JavaScript implementation (editor and save), styling, build tooling, PHP registration, dynamic/server-side rendering, attributes, transforms, deprecations and migrations, internationalization, linting, testing, TypeScript considerations, and debugging tips. Code examples are included for every important part the code blocks use the required

 wrapper so you can copy-paste directly.

Prerequisites

  • WordPress development environment (local WP site with WP 5.0 Gutenberg progressively integrated recommended latest WP).
  • Node.js (LTS recommended) and npm (or yarn).
  • Basic familiarity with ESNext / React-like JSX and WordPress block concepts (attributes, edit, save).

Two common starting routes

  1. Quick scaffold: Use npx @wordpress/create-block to scaffold a complete plugin. This is fast and sets up build, tests, translations and metadata. Example usage follows.
  2. Manual build with @wordpress/scripts: Create a plugin folder, add block metadata and source in a src/ folder, and use @wordpress/scripts for compiling and bundling. This tutorial focuses on the manual method but also shows the scaffold approach.

Quick scaffold (recommended to learn structure)

Run the official block creator to produce a ready-to-use plugin with @wordpress/scripts already configured:

npx @wordpress/create-block my-plugin-block

The generator asks a few questions and creates a plugin folder with src/, build tools, PHP registration and package.json scripts. Use it if you want a complete, best-practice baseline.

Manual setup using @wordpress/scripts (step-by-step)

1) Plugin folder and minimal plugin PHP file

Create a plugin folder in wp-content/plugins/your-block and a main PHP file. The PHP file will register block metadata and enqueue assets built by the JS build step.


The function register_block_type_from_metadata will read block.json in each block folder and automatically register scripts/styles built by the bundler if the expected asset files exist.

2) Initialize npm and install @wordpress/scripts

In the plugin root run:

npm init -y
npm install --save-dev @wordpress/scripts

If you want SCSS support, also install sass as a devDependency:

npm install --save-dev sass

3) package.json scripts

Add scripts to package.json for development and production builds. Minimal recommended scripts:

{
  name: my-custom-blocks,
  version: 1.0.0,
  private: true,
  devDependencies: {
    @wordpress/scripts: ^40.0.0,
    sass: ^1.32.0
  },
  scripts: {
    start: wp-scripts start,
    build: wp-scripts build,
    format: wp-scripts format,
    lint:js: wp-scripts lint-js,
    lint:css: wp-scripts lint-style,
    test: wp-scripts test-unit-js
  }
}

Use npm run start during development: it runs a dev server with watch rebuild and provides error overlays. Use npm run build when packaging for production.

4) Directory structure (recommended)

  1. plugin-folder/
    1. block-name/
      1. block.json
      2. src/
        1. index.js
        2. edit.js
        3. save.js
        4. editor.scss
        5. style.scss
      3. build/ (generated)
    2. my-custom-blocks.php (main plugin file)
    3. package.json

block.json (block metadata)

Using block.json is the recommended modern approach. register_block_type_from_metadata will use information here to register editor and frontend assets. Example:

{
  schema: https://schemas.wp.org/trunk/block.json,
  apiVersion: 2,
  name: my-plugin/notice,
  title: Notice,
  category: widgets,
  icon: megaphone,
  description: A simple notice block with title and content.,
  keywords: [alert, notice],
  version: 1.0.0,
  attributes: {
    title: {
      type: string,
      source: html,
      selector: .notice__title
    },
    content: {
      type: string,
      source: html,
      selector: .notice__content
    },
    isImportant: {
      type: boolean,
      default: false
    }
  },
  supports: {
    html: false,
    anchor: true,
    align: [wide,full]
  },
  textdomain: my-custom-blocks,
  editorScript: file:./build/index.js,
  editorStyle: file:./build/editor.css,
  style: file:./build/style.css
}

Notes:

  • apiVersion 2 enables block API v2 (recommended).
  • Use editorScript/style to point to the build outputs. register_block_type_from_metadata expects the build assets to exist at these paths/handles or will auto-generate handles if the files are present.
  • Attributes with source and selector are retrieved from saved markup. You can also store attributes in comment attributes or as plain attributes.

Writing the block JavaScript

Use the src/index.js as the entry. Typical patterns:

  • import edit and save implementations
  • import styles
  • register the block if youre not using server PHP register with block.json (or simply export to match block.json names)

Example: src/index.js (using registerBlockType directly)

import { registerBlockType } from @wordpress/blocks
import Edit from ./edit
import Save from ./save
import ./editor.scss
import ./style.scss

registerBlockType( my-plugin/notice, {
    apiVersion: 2,
    edit: Edit,
    save: Save
} )

If you use block.json and PHP register_block_type_from_metadata, you typically only need to export the editor script that calls registerBlockType with the same name in block.json.

Example: src/edit.js (editor UI with InspectorControls)

import { __ } from @wordpress/i18n
import { useBlockProps, RichText, InspectorControls } from @wordpress/block-editor
import { PanelBody, ToggleControl } from @wordpress/components
import { Fragment } from @wordpress/element

export default function Edit( { attributes, setAttributes } ) {
    const { title, content, isImportant } = attributes
    const blockProps = useBlockProps( { className: isImportant ? notice notice--important : notice } )

    return (
        
            
                
                     setAttributes( { isImportant: value } ) }
                    />
                
            
            
setAttributes( { title: value } ) } /> setAttributes( { content: value } ) } />
) }

Example: src/save.js (static save)

import { useBlockProps, RichText } from @wordpress/block-editor

export default function Save( { attributes } ) {
    const { title, content, isImportant } = attributes
    const blockProps = useBlockProps.save( { className: isImportant ? notice notice--important : notice } )

    return (
        
) }

Styling the block

Create editor-specific and front-end styles. Typical files: src/editor.scss and src/style.scss. Import them from index.js so @wordpress/scripts processes them.

/ editor.scss /
.notice {
  border-left: 4px solid #999
  padding: 12px
}
.notice--important {
  border-color: #d43535
}
/ style.scss (frontend) /
.notice__title { font-weight: bold margin-bottom: 8px }
.notice__content { color: #333 }

Build output and asset files

When you run wp-scripts build, the default behavior is to take src/ as an entry and produce build/ files. If you maintain your own entry names, ensure block.json points to the right built files. Typical build outputs are:

  • build/index.js — the compiled editor script
  • build/style.css — front-end styles
  • build/editor.css — editor styles

PHP registration via block.json (best practice)

With block.json in the block folder and the files above present, the PHP function register_block_type_from_metadata( __DIR__ ) in the plugin main file will register the block and enqueue the styles and scripts automatically. Example PHP (already shown earlier):

function my_custom_blocks_register() {
    register_block_type_from_metadata( __DIR__ )
}
add_action( init, my_custom_blocks_register )

If you need a dynamic block (rendered on server), use the render_callback in register_block_type or add in block.json by omitting save and providing server-side code. Example:

Dynamic block: PHP render_callback example

function my_notice_render( attributes ) {
    title = isset( attributes[title] ) ? attributes[title] : 
    content = isset( attributes[content] ) ? attributes[content] : 
    class = ! empty( attributes[isImportant] ) ? notice notice--important : notice

    ob_start()
    ?>
    
>

my_notice_render, ) )

Using ServerSideRender in the editor

For dynamic blocks, in the editor you can show the server-rendered markup for a realistic preview:

import ServerSideRender from @wordpress/server-side-render

export default function Edit( props ) {
    const { attributes, setAttributes } = props
    return (
        
    )
}

Attributes: types and sources

Attributes can be stored in multiple ways:

  • source: html — pulls inner HTML from a selector (commonly used with RichText).
  • source: attribute — reads an HTML attribute on a selector (useful for data- attributes or img src).
  • type — string, boolean, number, object, array.
  • default — default value when attribute missing.

Example attribute that stores image URL using attribute source:

attributes: {
  imageUrl: {
    type: string,
    source: attribute,
    attribute: src,
    selector: img
  }
}

Transforms and variations

Transforms let you convert other blocks into this block and vice versa. Variations are predefined presets shown in the block inserter. Basic examples:

// Inside registerBlockType or block.json for variations
variations: [
  {
    name: warning,
    title: Warning,
    attributes: { isImportant: true }
  },
  {
    name: info,
    title: Info,
    attributes: { isImportant: false }
  }
]

Deprecations and migrations

When you change saved markup or attributes, use the deprecated array on registerBlockType so older posts continue to render. Provide a migrate function to convert old attributes to new ones.

deprecated: [
  {
    attributes: { oldAttr: { type: string } },
    save: ( props ) => {
      // old save implementation
    },
    migrate: ( attributes ) => {
      return {
        newAttr: attributes.oldAttr ? attributes.oldAttr : 
      }
    }
  }
]

Internationalization

In JS use __ and related functions from @wordpress/i18n. In PHP load the textdomain in the plugin and register strings for translation. Example in JS:

import { __ } from @wordpress/i18n
const label = __( Mark as important, my-custom-blocks )

In PHP load translations:

function my_custom_blocks_load_textdomain() {
    load_plugin_textdomain( my-custom-blocks, false, dirname( plugin_basename( __FILE__ ) ) . /languages )
}
add_action( init, my_custom_blocks_load_textdomain )

Testing and linting

@wordpress/scripts ships testing and linting commands. You can run:

npm run lint:js
npm run lint:css
npm run test

Unit tests use Jest and the WordPress test environment. Example simple test:

// src/__tests__/index.test.js
import { registerBlockType } from @wordpress/blocks
jest.mock( @wordpress/blocks, () => ( { registerBlockType: jest.fn() } ) )

test( registers block, () => {
    require( ../index ) // index should register a block
    expect( registerBlockType ).toHaveBeenCalled()
} )

TypeScript considerations

TypeScript can be used, but requires additional configuration. In many projects people:

  • Rename files to .ts/.tsx.
  • Add a tsconfig.json.
  • Install type definitions like @types/react and types for WordPress packages if needed.
  • Optionally extend or override wp-scripts webpack/babel behavior with a custom config (advanced).

For full TypeScript integration consult the official docs or use community starters that wire up tsconfig and types. A simple approach is to keep most logic in plain JS and add TypeScript gradually.

Developer workflow and debugging

  • Use npm run start to watch changes and produce sourcemaps for easier debugging in the browser.
  • Open the editor and watch console for build errors. wp-scripts overlays errors in the browser.
  • Use the WordPress block inspector, and the browser devtools to inspect rendered DOM and block comment delimiters in post content.
  • Use window.wp.data and the block editor store (wp.data.select(core/block-editor)) for runtime inspection, but use it only for debugging—do not depend on private APIs in production code.

Advanced topics

Enqueuing additional editor-only scripts

If you need editor-only utilities, declare them as editorScript or editorStyle in block.json. register_block_type_from_metadata will map file: entries to handles and enqueue them automatically.

REST API and dynamic content

Dynamic blocks that include content pulled from the REST API can render in the editor using ServerSideRender or client-side fetch for advanced interactive editors. For heavy dynamic UIs, consider decoupling editor UI (React) from server render and return simple markup from render_callback.

Tips for accessible markup

  • Use semantic HTML (headings, paragraphs).
  • Ensure controls are reachable by keyboard and labels are clear.
  • Use appropriate ARIA roles when necessary.

Common pitfalls and solutions

  • Block not appearing in inserter: Check block.json name and category ensure build assets exist and register_block_type_from_metadata is run.
  • Attributes not persisted: Ensure save() returns markup matching attribute selectors and sources in block.json, or use save: null and a render_callback for dynamic server-side blocks.
  • Script references 404: Confirm build created the referenced file names and that PHP register code points to the plugin directory path.

Complete minimal example: plugin with one block

File layout and contents compact example. Main plugin file (my-custom-blocks.php):


block-name/block.json (simple metadata):

{
  schema: https://schemas.wp.org/trunk/block.json,
  apiVersion: 2,
  name: my-custom-blocks/simple,
  title: Simple Block,
  category: text,
  editorScript: file:./build/index.js,
  editorStyle: file:./build/editor.css,
  style: file:./build/style.css,
  attributes: {
    content: {
      type: string,
      source: html,
      selector: p
    }
  }
}

src/index.js:

import { registerBlockType } from @wordpress/blocks
import Edit from ./edit
import Save from ./save
import ./editor.scss
import ./style.scss

registerBlockType( my-custom-blocks/simple, {
    apiVersion: 2,
    edit: Edit,
    save: Save
} )

src/edit.js:

import { useBlockProps, RichText } from @wordpress/block-editor
import { __ } from @wordpress/i18n

export default function Edit( { attributes, setAttributes } ) {
    const blockProps = useBlockProps()
    return (
        
setAttributes( { content: value } ) } placeholder={ __( Write your text…, my-custom-blocks ) } />
) }

src/save.js:

import { useBlockProps, RichText } from @wordpress/block-editor

export default function Save( { attributes } ) {
    return (
        

) }

References and further reading

Checklist before shipping

  1. Run npm run build and confirm build files exist in block folder.
  2. Confirm register_block_type_from_metadata registers block without warnings.
  3. Test saving and editing the block, and verify older posts still render (handle deprecations if markup changed).
  4. Run lint and tests: npm run lint:js, npm run lint:css, npm run test.
  5. Prepare translations and load_plugin_textdomain in PHP.
  6. Bundle plugin and verify it installs and activates cleanly on a fresh WP install.

Final notes

@wordpress/scripts drastically reduces bundling configuration and provides standardized build tools for WordPress block development. Start from a small block, use block.json for metadata, import styles from your JS entry so they get processed, and rely on register_block_type_from_metadata in PHP to simplify registration. Use the scaffold tool for immediate productivity, and move to manual setup as you gain needs for customization.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *