By Devport Team | Last updated: 2025-07-12 | 20 min read

Creating Your First Custom Gutenberg Block (The Modern JavaScript Way)

Building custom Gutenberg blocks opens up endless possibilities for extending WordPress. Whether you're creating unique content layouts, integrating third-party services, or building interactive features, custom blocks give you complete control over the editing experience.

This comprehensive guide walks you through creating your first custom block using modern JavaScript and WordPress's official build tools. We'll start with a simple block and progressively add features, ensuring you understand not just the "how" but also the "why" behind each step.

Table of Contents

  1. Development Environment Setup
  2. Block Scaffolding with @wordpress/create-block
  3. Block Attributes and Controls
  4. Save and Edit Functions
  5. Block Styles and Variations
  6. Publishing to the Directory

Development Environment Setup

Prerequisites

Before creating your first block, ensure you have:

  1. Node.js and npm: Version 14.0 or higher
  2. Local WordPress Environment: Using Local, DevKinsta, or wp-env
  3. Code Editor: VS Code recommended with ESLint extension
  4. Basic Knowledge: JavaScript ES6+, React basics, WordPress fundamentals

Verifying Your Setup

# Check Node.js version
node --version
# Should output v14.0.0 or higher

# Check npm version
npm --version
# Should output 6.0.0 or higher

# Check WordPress version (in WordPress admin)
# Should be 5.8 or higher for best block support

Installing Development Tools

# Install WordPress development tools globally
npm install -g @wordpress/create-block

# Verify installation
npx @wordpress/create-block --version

Setting Up Your Development Workspace

# Navigate to your WordPress plugins directory
cd /path/to/wordpress/wp-content/plugins/

# Create a new directory for block development
mkdir my-custom-blocks
cd my-custom-blocks

Block Scaffolding with @wordpress/create-block

Creating Your First Block

The @wordpress/create-block tool provides the fastest way to bootstrap a new block with modern build configuration.

# Create a new block plugin
npx @wordpress/create-block my-first-block

# Navigate to the block directory
cd my-first-block

# Start development mode
npm start

Understanding the Generated Structure

my-first-block/
├── build/                    # Compiled production files
├── node_modules/            # Dependencies
├── src/
│   ├── block.json          # Block metadata
│   ├── index.js           # Block registration
│   ├── edit.js            # Editor component
│   ├── save.js            # Frontend output
│   ├── editor.scss        # Editor styles
│   └── style.scss         # Frontend + Editor styles
├── .editorconfig
├── .gitignore
├── package.json
├── readme.txt
└── my-first-block.php     # Plugin file

Exploring Key Files

block.json - Block Metadata

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "create-block/my-first-block",
  "version": "0.1.0",
  "title": "My First Block",
  "category": "widgets",
  "icon": "smiley",
  "description": "Example block scaffolded with Create Block tool.",
  "example": {},
  "supports": {
    "html": false
  },
  "textdomain": "my-first-block",
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "viewScript": "file:./view.js"
}

index.js - Block Registration

import { registerBlockType } from '@wordpress/blocks';
import './style.scss';
import Edit from './edit';
import save from './save';
import metadata from './block.json';

registerBlockType( metadata.name, {
  edit: Edit,
  save,
} );

edit.js - Editor Component

import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import './editor.scss';

export default function Edit() {
  return (
    <p { ...useBlockProps() }>
      { __( 'My First Block – hello from the editor!', 'my-first-block' ) }
    </p>
  );
}

Block Attributes and Controls

Adding Block Attributes

Attributes store the block's data. Let's create a testimonial block with various controls:

Update block.json

{
  "attributes": {
    "content": {
      "type": "string",
      "source": "html",
      "selector": "p.testimonial-content"
    },
    "authorName": {
      "type": "string",
      "source": "html",
      "selector": "p.testimonial-author"
    },
    "authorTitle": {
      "type": "string",
      "default": ""
    },
    "showImage": {
      "type": "boolean",
      "default": true
    },
    "imageUrl": {
      "type": "string",
      "default": ""
    },
    "imageId": {
      "type": "number",
      "default": 0
    },
    "backgroundColor": {
      "type": "string",
      "default": "#f0f0f0"
    },
    "textColor": {
      "type": "string",
      "default": "#333333"
    },
    "borderRadius": {
      "type": "number",
      "default": 8
    }
  }
}

Implementing Block Controls

Enhanced edit.js with Controls

import { __ } from '@wordpress/i18n';
import {
  useBlockProps,
  RichText,
  InspectorControls,
  BlockControls,
  MediaUpload,
  MediaUploadCheck,
  PanelColorSettings,
  AlignmentToolbar,
} from '@wordpress/block-editor';
import {
  PanelBody,
  TextControl,
  ToggleControl,
  RangeControl,
  Button,
  Toolbar,
  ToolbarButton,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import './editor.scss';

export default function Edit( { attributes, setAttributes } ) {
  const {
    content,
    authorName,
    authorTitle,
    showImage,
    imageUrl,
    imageId,
    backgroundColor,
    textColor,
    borderRadius,
    textAlignment,
  } = attributes;

  const blockProps = useBlockProps( {
    style: {
      backgroundColor,
      color: textColor,
      borderRadius: `${borderRadius}px`,
      padding: '2rem',
    },
  } );

  // Get image data from Media Library
  const image = useSelect(
    ( select ) => {
      return imageId ? select( 'core' ).getMedia( imageId ) : null;
    },
    [ imageId ]
  );

  const onSelectImage = ( media ) => {
    setAttributes( {
      imageUrl: media.url,
      imageId: media.id,
    } );
  };

  const onRemoveImage = () => {
    setAttributes( {
      imageUrl: '',
      imageId: 0,
    } );
  };

  return (
    <>
      <BlockControls>
        <AlignmentToolbar
          value={ textAlignment }
          onChange={ ( alignment ) =>
            setAttributes( { textAlignment: alignment } )
          }
        />
        <Toolbar>
          <ToolbarButton
            icon="admin-appearance"
            label={ __( 'Style Options', 'my-first-block' ) }
            onClick={ () => console.log( 'Style options clicked' ) }
          />
        </Toolbar>
      </BlockControls>

      <InspectorControls>
        <PanelBody
          title={ __( 'Testimonial Settings', 'my-first-block' ) }
          initialOpen={ true }
        >
          <ToggleControl
            label={ __( 'Show Author Image', 'my-first-block' ) }
            checked={ showImage }
            onChange={ ( value ) => setAttributes( { showImage: value } ) }
          />

          { showImage && (
            <MediaUploadCheck>
              <MediaUpload
                onSelect={ onSelectImage }
                allowedTypes={ [ 'image' ] }
                value={ imageId }
                render={ ( { open } ) => (
                  <div className="image-selector">
                    { imageUrl ? (
                      <div className="image-preview">
                        <img src={ imageUrl } alt="" />
                        <Button
                          isSecondary
                          isSmall
                          onClick={ onRemoveImage }
                        >
                          { __( 'Remove', 'my-first-block' ) }
                        </Button>
                      </div>
                    ) : (
                      <Button isPrimary onClick={ open }>
                        { __( 'Select Image', 'my-first-block' ) }
                      </Button>
                    ) }
                  </div>
                ) }
              />
            </MediaUploadCheck>
          ) }

          <TextControl
            label={ __( 'Author Title/Company', 'my-first-block' ) }
            value={ authorTitle }
            onChange={ ( value ) => setAttributes( { authorTitle: value } ) }
          />

          <RangeControl
            label={ __( 'Border Radius', 'my-first-block' ) }
            value={ borderRadius }
            onChange={ ( value ) => setAttributes( { borderRadius: value } ) }
            min={ 0 }
            max={ 50 }
          />
        </PanelBody>

        <PanelColorSettings
          title={ __( 'Color Settings', 'my-first-block' ) }
          colorSettings={ [
            {
              value: backgroundColor,
              onChange: ( color ) =>
                setAttributes( { backgroundColor: color } ),
              label: __( 'Background Color', 'my-first-block' ),
            },
            {
              value: textColor,
              onChange: ( color ) => setAttributes( { textColor: color } ),
              label: __( 'Text Color', 'my-first-block' ),
            },
          ] }
        />
      </InspectorControls>

      <div { ...blockProps }>
        <blockquote
          className="testimonial-wrapper"
          style={ { textAlign: textAlignment } }
        >
          <RichText
            tagName="p"
            className="testimonial-content"
            value={ content }
            onChange={ ( value ) => setAttributes( { content: value } ) }
            placeholder={ __( 'Write testimonial…', 'my-first-block' ) }
          />

          <div className="testimonial-author-wrapper">
            { showImage && imageUrl && (
              <img
                src={ imageUrl }
                alt=""
                className="testimonial-author-image"
              />
            ) }
            <div className="testimonial-author-info">
              <RichText
                tagName="p"
                className="testimonial-author"
                value={ authorName }
                onChange={ ( value ) =>
                  setAttributes( { authorName: value } )
                }
                placeholder={ __( 'Author name…', 'my-first-block' ) }
              />
              { authorTitle && (
                <p className="testimonial-author-title">{ authorTitle }</p>
              ) }
            </div>
          </div>
        </blockquote>
      </div>
    </>
  );
}

Save and Edit Functions

Understanding the Save Function

The save function determines how your block appears on the frontend:

save.js - Frontend Output

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

export default function save( { attributes } ) {
  const {
    content,
    authorName,
    authorTitle,
    showImage,
    imageUrl,
    backgroundColor,
    textColor,
    borderRadius,
    textAlignment,
  } = attributes;

  const blockProps = useBlockProps.save( {
    style: {
      backgroundColor,
      color: textColor,
      borderRadius: `${borderRadius}px`,
      padding: '2rem',
    },
  } );

  return (
    <div { ...blockProps }>
      <blockquote
        className="testimonial-wrapper"
        style={ { textAlign: textAlignment } }
      >
        <RichText.Content
          tagName="p"
          className="testimonial-content"
          value={ content }
        />

        <div className="testimonial-author-wrapper">
          { showImage && imageUrl && (
            <img
              src={ imageUrl }
              alt=""
              className="testimonial-author-image"
            />
          ) }
          <div className="testimonial-author-info">
            <RichText.Content
              tagName="p"
              className="testimonial-author"
              value={ authorName }
            />
            { authorTitle && (
              <p className="testimonial-author-title">{ authorTitle }</p>
            ) }
          </div>
        </div>
      </blockquote>
    </div>
  );
}

Block Deprecation Strategy

When updating block structure, implement deprecations to maintain backward compatibility:

// In index.js
import deprecated from './deprecated';

registerBlockType( metadata.name, {
  edit: Edit,
  save,
  deprecated,
} );

// deprecated.js
const v1 = {
  attributes: {
    // Previous attributes
  },
  save( { attributes } ) {
    // Previous save function
  },
};

export default [ v1 ];

Block Styles and Variations

Adding Block Styles

style.scss - Frontend & Editor Styles

.wp-block-create-block-my-first-block {
  .testimonial-wrapper {
    margin: 0;
    position: relative;

    &::before {
      content: '"';
      font-size: 4rem;
      position: absolute;
      top: -1rem;
      left: -1rem;
      opacity: 0.2;
      line-height: 1;
    }
  }

  .testimonial-content {
    font-size: 1.125rem;
    line-height: 1.6;
    margin-bottom: 1.5rem;
    font-style: italic;
  }

  .testimonial-author-wrapper {
    display: flex;
    align-items: center;
    gap: 1rem;
  }

  .testimonial-author-image {
    width: 60px;
    height: 60px;
    border-radius: 50%;
    object-fit: cover;
  }

  .testimonial-author-info {
    .testimonial-author {
      margin: 0;
      font-weight: 600;
      font-style: normal;
    }

    .testimonial-author-title {
      margin: 0;
      opacity: 0.7;
      font-size: 0.875rem;
    }
  }
}

editor.scss - Editor-Only Styles

.wp-block-create-block-my-first-block {
  .image-selector {
    margin-top: 1rem;

    .image-preview {
      position: relative;

      img {
        max-width: 100%;
        height: auto;
        display: block;
        margin-bottom: 0.5rem;
      }
    }
  }

  // Add visual hints in editor
  .testimonial-content:empty::before {
    content: attr(placeholder);
    opacity: 0.5;
  }
}

Creating Block Variations

Add variations to block.json:

{
  "variations": [
    {
      "name": "testimonial-centered",
      "title": "Centered Testimonial",
      "attributes": {
        "textAlignment": "center"
      },
      "isDefault": true
    },
    {
      "name": "testimonial-left",
      "title": "Left Aligned Testimonial",
      "attributes": {
        "textAlignment": "left"
      }
    }
  ],
  "styles": [
    {
      "name": "default",
      "label": "Default",
      "isDefault": true
    },
    {
      "name": "plain",
      "label": "Plain"
    },
    {
      "name": "bordered",
      "label": "Bordered"
    }
  ]
}

Block Supports

Enable additional features through block supports:

{
  "supports": {
    "html": false,
    "align": ["wide", "full"],
    "anchor": true,
    "customClassName": true,
    "spacing": {
      "margin": true,
      "padding": true
    },
    "color": {
      "text": true,
      "background": true,
      "gradients": true
    },
    "typography": {
      "fontSize": true,
      "lineHeight": true
    }
  }
}

Publishing to the Directory

Preparing for Release

1. Production Build

# Create optimized production build
npm run build

# Check build output
ls -la build/

2. Plugin Header Requirements

Update my-first-block.php:

<?php
/**
 * Plugin Name:       My First Block
 * Description:       Example block scaffolded with Create Block tool.
 * Requires at least: 6.1
 * Requires PHP:      7.0
 * Version:           0.1.0
 * Author:            Your Name
 * Author URI:        https://example.com
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       my-first-block
 *
 * @package           create-block
 */

if ( ! defined( 'ABSPATH' ) ) {
  exit; // Exit if accessed directly.
}

function create_block_my_first_block_block_init() {
  register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'create_block_my_first_block_block_init' );

3. readme.txt Requirements

=== My First Block ===
Contributors: yourusername
Tags: block, gutenberg, testimonial
Tested up to: 6.3
Stable tag: 0.1.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

A beautiful testimonial block for the WordPress block editor.

== Description ==

This plugin adds a customizable testimonial block to your WordPress block editor. Features include:

* Rich text editing for testimonial content
* Author name and title fields
* Optional author image
* Customizable colors and border radius
* Multiple style variations

== Installation ==

1. Upload the plugin files to the `/wp-content/plugins/my-first-block` directory
2. Activate the plugin through the 'Plugins' screen in WordPress
3. Search for "My First Block" in the block editor

== Frequently Asked Questions ==

= How do I customize the testimonial appearance? =

Use the block settings in the sidebar to adjust colors, border radius, and other visual properties.

== Screenshots ==

1. The testimonial block in the editor
2. Block settings panel
3. Frontend display

== Changelog ==

= 0.1.0 =
* Initial release

Submission Process

1. Create Plugin ZIP

# From plugin directory
npm run plugin-zip

# This creates my-first-block.zip

2. Test the ZIP

3. Submit to WordPress.org

  1. Create a WordPress.org account
  2. Go to https://wordpress.org/plugins/developers/add/
  3. Upload your ZIP file
  4. Fill in plugin details
  5. Submit for review

Post-Submission

Setting Up SVN

# After approval, clone SVN repository
svn co https://plugins.svn.wordpress.org/my-first-block

# Copy files to trunk
cp -r /path/to/plugin/* trunk/

# Add files
svn add trunk/* --force

# Commit
svn ci -m "Initial release"

# Tag release
svn cp trunk tags/0.1.0
svn ci -m "Tagging version 0.1.0"

Advanced Block Development

Adding Dynamic Data

// Fetch data from WordPress
import { useSelect } from '@wordpress/data';

const posts = useSelect( ( select ) => {
  return select( 'core' ).getEntityRecords( 'postType', 'post', {
    per_page: 3,
  } );
}, [] );

Creating Inner Blocks

import { InnerBlocks } from '@wordpress/block-editor';

const ALLOWED_BLOCKS = [ 'core/paragraph', 'core/image' ];
const TEMPLATE = [
  [ 'core/paragraph', { placeholder: 'Add content...' } ],
];

// In edit function
<InnerBlocks
  allowedBlocks={ ALLOWED_BLOCKS }
  template={ TEMPLATE }
/>

// In save function
<InnerBlocks.Content />

Adding Block Transforms

transforms: {
  from: [
    {
      type: 'block',
      blocks: [ 'core/paragraph' ],
      transform: ( { content } ) => {
        return createBlock( 'create-block/my-first-block', {
          content,
        } );
      },
    },
  ],
  to: [
    {
      type: 'block',
      blocks: [ 'core/paragraph' ],
      transform: ( { content } ) => {
        return createBlock( 'core/paragraph', {
          content,
        } );
      },
    },
  ],
}

Best Practices

1. Performance Optimization

2. Accessibility

3. Internationalization

import { __ } from '@wordpress/i18n';

// Always wrap strings
__( 'Text to translate', 'text-domain' )

4. Error Handling

try {
  // Block logic
} catch ( error ) {
  console.error( 'Block error:', error );
  return (
    <div { ...blockProps }>
      { __( 'Block error. Please check settings.', 'my-first-block' ) }
    </div>
  );
}

Conclusion

Creating custom Gutenberg blocks opens up incredible possibilities for extending WordPress. With modern JavaScript tools and WordPress's robust block API, you can build powerful, user-friendly blocks that enhance the editing experience.

Key takeaways: - Use @wordpress/create-block for quick setup - Understand the relationship between edit and save functions - Leverage WordPress components for consistent UI - Test thoroughly before publishing - Follow WordPress coding standards

Start simple, iterate often, and don't be afraid to explore the vast ecosystem of WordPress blocks. Your first block is just the beginning of your journey into modern WordPress development.


Ready to dive deeper into WordPress development? Explore our Ultimate Guide to Gutenberg and Full-Site Editing for more advanced techniques and best practices.