Why Custom Gutenberg Blocks Matter

Gutenberg changed how we build WordPress sites. Instead of shortcodes and bulky page builders, it gives developers a modular, React based system that is flexible and future proof. Core blocks cover a lot, but sometimes you need something more, a tailored block that matches your editor workflow or brand.

Custom blocks let you package structure and design into a single, reusable component that editors can drop anywhere. The good news is you do not need a huge setup to start. With the official tooling, you can scaffold, edit, style, and ship a minimal block quickly, while keeping it maintainable.

Step 1: Set Up Your Development Environment

Make sure you have a recent WordPress install and that Node.js and npm are available.


For a reusable feature, prefer a plugin over a theme. You will work inside wp-content/plugins.

Step 2: Use the Official WP Scripts Tool

The fastest path is @wordpress/create-block. It generates a ready to build block plugin with sane defaults, a block.json, build scripts, and PHP loader.

Run it in your WordPress root or inside wp-content/plugins:


npx @wordpress/create-block my-custom-block
Activate My Custom Block in Plugins. During development, use the watcher for instant rebuilds:


cd wp-content/plugins/my-custom-block
npm install
npm start

Tip: npm start runs the dev build and watches files. Use npm run build for production, which minifies and hashes assets.

Step 3: Understand the Block Structure

A minimal block has three concerns: metadata, editor UI, and saved markup.

  • block.json registers the block and its assets
  • src/edit.js controls editor experience
  • src/save.js outputs front end HTML for static blocks

Here is a simple block.json with one attribute and helpful supports:

{
  "apiVersion": 2,
  "name": "logicware/custom-cta",
  "title": "Custom Call to Action",
  "category": "widgets",
  "icon": "megaphone",
  "description": "A simple CTA block with editable label and URL.",
  "attributes": {
    "text": {
      "type": "string",
      "default": "Learn more"
    },
    "url": {
      "type": "string",
      "default": "#"
    },
    "isNewTab": {
      "type": "boolean",
      "default": false
    }
  },
  "supports": {
    "html": false,
    "align": [
      "wide",
      "full"
    ],
    "spacing": {
      "margin": true,
      "padding": true
    }
  },
  "editorScript": "file:./build/index.js",
  "style": "file:./build/style-index.css",
  "editorStyle": "file:./build/index.css"
}

Why this matters: attributes define what can be edited, supports control features like alignment and spacing, and the style fields connect your compiled CSS.

Step 4: Write Your Block Logic

Open src/edit.js. Provide controls for the text, URL, and target. Use inspector controls for settings and plain controls for inline edits.

import {
  useBlockProps,
  InspectorControls,
  RichText,
  __experimentalLinkControl as LinkControl,
} from "@wordpress/block-editor";
import { PanelBody, ToggleControl } from "@wordpress/components";

export default function Edit({ attributes, setAttributes }) {
  const { text, url, isNewTab } = attributes;
  const blockProps = useBlockProps({ className: "logicware-cta" });

  return (
    <>
      <InspectorControls>
        <PanelBody title="Link Settings" initialOpen={true}>
          <LinkControl
            value={{ url }}
            onChange={(val) => setAttributes({ url: val?.url || "" })}
            settings={[]}
          />
          <ToggleControl
            label="Open in new tab"
            checked={isNewTab}
            onChange={(val) => setAttributes({ isNewTab: val })}
          />
        </PanelBody>
      </InspectorControls>

      <div {...blockProps}>
        <RichText
          tagName="a"
          className="cta-button"
          value={text}
          onChange={(val) => setAttributes({ text: val })}
          placeholder="Button text"
          allowedFormats={[]}
        />
      </div>
    </>
  );
}

Notes: RichText makes the label editable inline, LinkControl keeps URL handling consistent with core, and all state is stored in attributes.

Step 5: Define What Appears on the Front End

Static blocks serialize their content into post HTML. Keep output simple and accessible.

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

export default function save({ attributes }) {
  const { text, url, isNewTab } = attributes;
  const blockProps = useBlockProps.save({ className: "logicware-cta" });

  const rel = isNewTab ? "noopener noreferrer" : undefined;
  const target = isNewTab ? "_blank" : undefined;

  return (
    <div {...blockProps}>
      <RichText.Content
        tagName="a"
        className="cta-button"
        value={text}
        href={url || "#"}
        target={target}
        rel={rel}
      />
    </div>
  );
}

Run a production build when ready to test on a non dev site:


Step 6: Add Editor and Front End Styles

Create src/style.scss for front end and src/editor.scss for editor only tweaks. These get compiled into the files referenced by block.json.


/* src/style.scss */
.logicware-cta .cta-button {
display: inline-block;
background: #007aff;
color: #fff;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
text-decoration: none;
font-weight: 600;
}

.logicware-cta .cta-button:hover {
opacity: 0.9;
}



/* src/editor.scss */
.logicware-cta {
outline: 1px dashed #cfd3d7;
padding: 0.5rem;
}

Tip: keep editor styling light. The goal is clarity while editing, not full visual parity.

Step 7: Know the PHP Loader and Dynamic Option

The scaffold adds a plugin bootstrap file like my-custom-block.php. It registers the block based on block.json and exposes localization.

For dynamic content that must render on the server, add a render.php and point "render" in block.json to a PHP callback. That pattern is useful for shortcodes, queries, or external data.

  <?php // render.php
  function logicware_render_cta($attributes)
  {
      $text = isset($attributes["text"])
          ? wp_kses_post($attributes["text"])
          : "Learn more";
      $url = isset($attributes["url"]) ? esc_url($attributes["url"]) : "#";
      $is_new = !empty($attributes["isNewTab"]);

      $target = $is_new ? ' target="_blank"' : "";
      $rel = $is_new ? ' rel="noopener noreferrer"' : "";

      return '<div class="logicware-cta"><a class="cta-button" href="' .
          $url .
          '"' .
          $target .
          $rel .
          ">" .
          $text .
          "</a></div>";
  }

In block.json:

{
    "render": "file:./render.php"
}

Rule: always escape URLs with esc_url and HTML with wp_kses_post.

Step 8: Accessibility and Usability Essentials

Keep link text meaningful, avoid empty anchors, honor new tab settings, and ensure focus states are visible. If you add color controls later, provide sufficient contrast. For keyboard users, confirm the anchor is focusable and that any controls in the Inspector are labeled.

Step 9: Build and Ship

During development:

For production:

Zip the plugin folder or deploy through your version control process.

Bonus Tips: Make It Polished

  • Use block metadata for attributes, styles, and supports so you avoid manual register_block_type arguments.
  • Keep dependencies small. Most simple blocks need only @wordpress/block-editor and @wordpress/components.
  • Add example in block.json to show a live preview in the inserter.
  • Test the block with different themes and in the Site Editor to confirm styles are resilient.

Helpful references:

Final Thoughts

A minimal custom block is a handful of files, a clear set of attributes, light styles, and a steady build. Start small, ship a clean version, then layer in controls for colors, spacing, and variations as your needs grow. The structure above keeps the code predictable and easy to maintain in client projects.

Leave a Reply