← Back to all posts
September 4, 2026
•

How to Write a Custom Gutenberg Block Plugin Without Breaking Your Layout

Laptop showing lines of code on a desk, illustrating how to write a custom Gutenberg block plugin

The request usually arrives after a redesign. The site has a testimonial card, a pricing row or a “team member” layout that editors need to add to pages themselves, and the options are a reusable block that breaks the moment somebody edits it, a shortcode nobody remembers, or a page builder widget that locks the site to that builder. The right answer is to write custom Gutenberg block plugin code: a small plugin that registers one or more blocks, renders them with markup you control, and keeps working when the theme changes.

This is the process we follow on our custom Gutenberg block plugin development service, written for a technical lead or agency owner who wants to know what a properly built block looks like before commissioning one or building it in-house. It covers the static-versus-dynamic decision, scaffolding with the official tooling, the editor and render sides, styling that respects the theme, and the deprecation and testing steps that separate a block that lasts from one that gets rebuilt in a year.

before you write custom Gutenberg block plugin code: static or dynamic

A block’s save() function decides its future. A static block serialises its HTML into post_content at save time. Change the markup later and every existing instance shows a “This block contains unexpected or invalid content” error until you write a deprecation. A dynamic block stores only attributes in the post and renders the HTML in PHP on every request, so you can change the markup freely.

Static block Dynamic block
Markup lives in post_content render.php at request time
Changing the HTML later Requires a deprecation entry Edit the template
Can query live data No Yes (recent posts, prices, ACF)
Works in REST content Yes, pre-rendered Rendered via /wp/v2/block-renderer or GraphQL
Best for Simple, stable layout such as a quote Anything client-specific

For client work we default to dynamic blocks. The one exception is content that must be fully editable and exported as clean HTML, such as a pull quote. Note that a dynamic block still needs a React edit component; only the front end rendering moves to PHP.

scaffold with the official tooling

Do not hand-roll a webpack config when you write custom Gutenberg block plugin code. The @wordpress/create-block package produces a plugin with the correct build pipeline, a block.json and, with the dynamic variant, a render.php:

npx @wordpress/create-block@latest swift-testimonial 
  --namespace swift --variant dynamic --title "Testimonial" 
  --category widgets
cd swift-testimonial
npm start          # watch mode while developing
npm run build      # production build into build/
wp plugin activate swift-testimonial

The block.json is the block’s contract. Attributes, editor supports, scripts and styles all live here so WordPress can register assets lazily and the block shows up correctly in the inserter:

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "swift/testimonial",
  "version": "1.0.0",
  "title": "Testimonial",
  "category": "widgets",
  "icon": "format-quote",
  "textdomain": "swift-testimonial",
  "attributes": {
    "quote":  { "type": "string", "source": "html", "selector": ".swift-testimonial__quote" },
    "author": { "type": "string", "default": "" },
    "role":   { "type": "string", "default": "" },
    "imageId": { "type": "integer" }
  },
  "supports": {
    "align": [ "wide", "full" ],
    "spacing": { "margin": true, "padding": true },
    "color": { "background": true, "text": true },
    "typography": { "fontSize": true },
    "html": false
  },
  "editorScript": "file:./index.js",
  "editorStyle": "file:./index.css",
  "style": "file:./style-index.css",
  "render": "file:./render.php"
}

Two choices in that file protect the layout. "html": false stops editors from switching to the HTML view and breaking the structure. And the supports block opts into core’s spacing, colour and typography controls so editors adjust those through the standard sidebar instead of asking for custom fields that later collide with the theme.

the editor side: edit.js

The edit component should look like the front end and expose only the controls editors need. useBlockProps() applies the wrapper class and the attributes from supports; RichText handles inline text; InspectorControls holds the sidebar fields:

import { __ } from '@wordpress/i18n';
import { useBlockProps, RichText, InspectorControls, MediaUpload } from '@wordpress/block-editor';
import { PanelBody, TextControl, Button } from '@wordpress/components';

export default function Edit( { attributes, setAttributes } ) {
  const { quote, author, role, imageId } = attributes;
  return (
    <>
      <InspectorControls>
        <PanelBody title={ __( 'Attribution', 'swift-testimonial' ) }>
          <TextControl label={ __( 'Name', 'swift-testimonial' ) } value={ author }
            onChange={ ( v ) => setAttributes( { author: v } ) } />
          <TextControl label={ __( 'Role', 'swift-testimonial' ) } value={ role }
            onChange={ ( v ) => setAttributes( { role: v } ) } />
          <MediaUpload allowedTypes={ [ 'image' ] } value={ imageId }
            onSelect={ ( media ) => setAttributes( { imageId: media.id } ) }
            render={ ( { open } ) => <Button variant="secondary" onClick={ open }>{ __( 'Choose photo', 'swift-testimonial' ) }</Button> } />
        </PanelBody>
      </InspectorControls>
      <blockquote { ...useBlockProps( { className: 'swift-testimonial' } ) }>
        <RichText tagName="p" className="swift-testimonial__quote" value={ quote }
          allowedFormats={ [ 'core/bold', 'core/italic' ] }
          placeholder={ __( 'Add the quote', 'swift-testimonial' ) }
          onChange={ ( v ) => setAttributes( { quote: v } ) } />
        <footer className="swift-testimonial__meta">{ author }{ role && `, ${ role }` }</footer>
      </blockquote>
    </>
  );
}

Restricting allowedFormats is deliberate. Editors will paste from Google Docs, and an unrestricted RichText will happily store inline colours and font sizes that fight the theme.

render on the server: render.php

For a dynamic block, render.php receives $attributes, $content and $block. Use get_block_wrapper_attributes() so the classes and inline styles generated by supports land on the wrapper, and escape everything:

<?php
$quote   = $attributes['quote'] ?? '';
$author  = $attributes['author'] ?? '';
$role    = $attributes['role'] ?? '';
$image   = ! empty( $attributes['imageId'] )
    ? wp_get_attachment_image( (int) $attributes['imageId'], 'thumbnail', false, [ 'loading' => 'lazy' ] )
    : '';
?>
<blockquote <?php echo get_block_wrapper_attributes( [ 'class' => 'swift-testimonial' ] ); ?>>
    <?php echo $image; // already escaped by core ?>
    <p class="swift-testimonial__quote"><?php echo wp_kses_post( $quote ); ?></p>
    <footer class="swift-testimonial__meta">
        <?php echo esc_html( $author ); ?><?php echo $role ? ', ' . esc_html( $role ) : ''; ?>
    </footer>
</blockquote>

Because the HTML is produced here, a redesign next year means editing one file. No deprecations, no content migration, no broken blocks in the editor.

styling without breaking the layout

Most “the block broke my page” reports are CSS, not JavaScript. The rules we hold to:

  • Consume theme tokens, do not redefine them. Use the custom properties WordPress generates from theme.json, such as var(--wp--preset--color--primary) and var(--wp--preset--spacing--40), so the block follows the theme’s palette and spacing scale automatically.
  • Scope every selector to the block class. .swift-testimonial__quote, never a bare blockquote p. BEM naming keeps this honest.
  • No widths, no margins on the wrapper. Layout belongs to the theme and to supports.spacing. A block that sets its own max-width will look wrong in the next theme.
  • No !important. If you need it, your selector is fighting the theme and the theme will win eventually.
  • Load styles only when the block is present. Registering style in block.json does this for you. If the block ships JavaScript for the front end, put it in viewScriptModule and keep it small; unnecessary scripts are one of the things we remove during Core Web Vitals optimization work.

If the site runs a page builder such as Bricks alongside the block editor, keep the block’s CSS self-contained and test it inside the builder’s post content element. The same discipline makes the block usable on a headless front end, where the block attributes are fetched over the API and rendered in React, as we describe in our headless WordPress with React walkthrough.

deprecations, variations and patterns

If you did ship a static block and need to change its markup, add a deprecated array to the block registration containing the previous save() and attributes, plus a migrate() function if attribute names changed. WordPress will upgrade old instances silently on the next edit. Keep every historical version in that array; removing one breaks posts that were never re-saved.

For layouts that combine several blocks (a testimonial grid, a pricing section), do not build one giant block. Register a block pattern with register_block_pattern() that composes core Group and Columns blocks with your testimonial block inside, and lock the structure with "lock": { "move": true, "remove": true } where editors should not rearrange it. Block variations (registerBlockVariation) give you preset attribute combinations, for example “Testimonial: dark”, without a second block.

build, test and ship

Run npm run build and commit the build/ directory or produce it in CI; never deploy from npm start output. Test the plugin in @wordpress/env (npx wp-env start) against the current WordPress release and the previous major version. Run PHPCS with the WordPress Coding Standards ruleset and PHPCompatibilityWP against your target PHP versions; the deprecations that matter are listed in our PHP 8 compatibility guide. Generate translation files with wp i18n make-pot . languages/swift-testimonial.pot and wp i18n make-json so the editor strings are translatable. Finally, insert the block into a real page, resize the browser to phone width, switch themes to Twenty Twenty-Four and back, and confirm nothing moves that should not.

when to do this yourself vs hire someone

A developer who is comfortable with React and has read the Block Editor Handbook can write custom Gutenberg block plugin code for a simple card or callout in a day or two, and should. It becomes worth hiring when you need a family of blocks that share a design system, blocks that query live data (products, events, ACF relationships), InnerBlocks with locked templates for editors, or when the blocks must also render on a headless front end. Those projects live or die on the conventions above, and a team that has already made the mistakes is faster and cheaper than learning them on your site. Blocks like these are also standard scope inside our full website development builds.

get a fixed quote for your blocks

Describe the components your editors need and where they will be used, and we will reply within 24 hours with a fixed scope and price for a custom Gutenberg block plugin built the way this post describes, with the source in your repository and no lock-in to us. Use the contact page to send us the brief; you will be talking to the engineer who writes the code.

Share this post

keep reading

Portable external hard drive connected to a laptop, illustrating an automated WordPress backup system
September 22, 2026
•

Automated WordPress Backup System Setup: Offsite, Versioned and Actually Tested

Automated WordPress backup system setup done properly: offsite S3 storage, versioned retention, write-only credentials, hourly store backups, tested restores.

Read more
Page speed test results on a monitor, illustrating Core Web Vitals optimization for WordPress
September 20, 2026
•

Core Web Vitals Optimization for WordPress: LCP, INP and CLS Fixes That Actually Work

Core Web Vitals optimization for WordPress, metric by metric: the LCP, INP and CLS fixes in theme, plugins and server that move…

Read more
Glass cloud icon with data layers above a padlock, illustrating AWS hosting migration for WordPress
September 18, 2026
•

AWS Hosting Migration for WordPress: Lightsail vs EC2 vs Managed Hosting

AWS hosting migration for WordPress compared: Lightsail vs EC2 with RDS vs managed hosting, the migration steps we run, and what breaks…

Read more

want this kind of thinking on your project?

Tell us what you are building and we'll come back with a straight answer on scope, timeline and cost within 24 hours.
start a project