Enterprise WordPress · 7 min read

Gutenberg Block Development: A Production Architecture Guide

Attribute schemas, deprecations, static versus dynamic rendering, and the block architecture decisions that decide whether your content stays portable.

By Praful Patel · Last updated
Gutenberg Block Development: A Production Architecture Guide - article cover
Gutenberg Block Development: A Production Architecture Guide - article cover

Short answer

A Gutenberg block is a data schema with a renderer attached, and the schema is the part you cannot change cheaply later. Attributes define what the content is; the edit and save functions are just one presentation of it. Teams that treat blocks as UI components ship fast and then discover that every design change is a content migration.

Custom blocks fail in production for a small number of repeated reasons: attributes that encode layout, markup parsed for meaning instead of stored as data, missing deprecations, and a block palette that grew without anyone deciding it should. None of these hurt during the build. All of them hurt eighteen months later.

Attributes are the contract

Everything downstream depends on the attribute schema — the editor, the front end, the REST and GraphQL representations, and any future headless consumer. Get this right and the rest is replaceable.

Store data, not markup

The source option lets an attribute be parsed back out of saved HTML. It is convenient and it is a trap for anything structural. An attribute sourced from html means the saved markup is your database, and every consumer has to re-implement the parsing to read it.

// Fragile: the value only exists inside the saved markup.
"heading": { "type": "string", "source": "html", "selector": "h2" }

// Portable: the value is data, and the markup is generated from it.
"heading":      { "type": "string", "default": "" },
"headingLevel": { "type": "number", "default": 2 }

Sourced attributes are reasonable for genuine rich text, where the markup is the content. They are the wrong choice for a heading level, a link target, a layout option or anything a front end needs to reason about.

Name attributes for meaning, not position

columnCount is a layout decision that will change. items is a fact about the content that will not. When a block has backgroundColorDesktop and backgroundColorMobile, the design has been encoded into the content model, and the next redesign becomes a database problem.

Static or dynamic rendering

The choice is not stylistic. It determines whether saved content can go stale.

Static (save) Dynamic (render_callback)
Markup lives in post_content PHP, at request time
Changing the markup Requires a deprecation Deploy and done
Queries data Must not Yes
Front-end cost Zero Runs on every uncached request
Fails as Validation error in the editor A slow or empty page

The rule: anything that queries or fetches must be dynamic. A block that bakes the three latest posts into saved content is wrong the moment a fourth is published, and nobody notices because the page still renders.

Dynamic blocks then inherit the caching obligations of any template code. A render_callback that issues an unbounded query runs on every uncached view — the same constraint described in Engineering for WPVIP.

Deprecations: the part everyone skips

Change a static block’s save output and every existing instance fails validation. Editors see “this block contains unexpected or invalid content” and their options are to lose the block or accept whatever the recovery produces.

A deprecation entry describes the old shape so the editor can migrate content silently:

deprecated: [
  {
    attributes: oldAttributes,
    // Runs once, converting old attributes to the current schema.
    migrate: ({ columns, ...rest }) => ({ ...rest, layout: columns > 1 ? 'grid' : 'stack' }),
    save: ({ attributes }) => /* the previous markup, verbatim */,
  },
]

Two practical rules. Keep deprecations in reverse-chronological order, newest first — the editor tries them in sequence. And never delete an old entry because it looks like clutter: it is the only thing standing between existing content and a validation error.

block.json is the single source of truth

Registering metadata in block.json rather than in JavaScript means the same definition is available to PHP, to the editor, and to asset loading. It is also what lets WordPress enqueue block assets only on pages where the block appears, rather than shipping every block’s CSS to every visitor.

{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "acme/callout",
  "title": "Callout",
  "category": "text",
  "attributes": {
    "tone": { "type": "string", "enum": ["info", "warning"], "default": "info" }
  },
  "supports": { "html": false, "anchor": true },
  "editorScript": "file:./index.js",
  "style": "file:./style.css"
}

"html": false is worth setting deliberately. It removes the “Edit as HTML” option, which otherwise lets an editor hand-modify markup into a state your save function would never produce — and which will fail validation on the next deploy.

Constrain the palette

Every block available to an editor is a shape the front end must handle, a variant QA must check, and a migration path someone must maintain. Core plus a handful of plugins can expose sixty blocks, most of which no one on the team chose.

Use allowed_block_types_all to curate a deliberate set, and prefer block variations and styles over new block types when the difference is presentational. A block with three registered styles is one thing to maintain; three near-identical blocks are three.

Testing blocks

  • Fixture round-trips. Serialise a block, parse it back, assert the attributes match. This is what catches accidental schema changes before editors do.
  • Deprecation coverage. Parse markup saved by every previous version and assert it migrates without a validation error.
  • Render tests for dynamic blocks. Including the empty case — a query that returns nothing should render something sensible, not a fatal error.
  • Query counting. Assert a render_callback stays inside its query budget, the same discipline covered in Full-Stack Web Performance.

Blocks as a headless content model

If the content will ever be consumed by something other than a PHP theme, blocks are already the right shape: an ordered list of typed components with validated attributes. That only holds if the attributes carry the data. A block whose meaning lives in its saved HTML forces every consumer to parse markup, which is the problem headless was supposed to remove — covered further in Building Scalable Headless CMS & Gutenberg Architectures.

Questions people actually ask

Should I use ACF blocks or native Gutenberg blocks?
ACF blocks are faster to build and keep rendering in PHP, which suits teams who are strong in PHP and are not going headless. Native blocks give a better editing experience, work without a plugin dependency, and serialise cleanly for any consumer. For a long-lived platform, or anything that may go headless, native blocks are the safer investment.

Why do I keep getting “unexpected or invalid content”?
The block’s current save output no longer matches the markup stored in existing posts. Something in the save function or the attribute schema changed without a matching deprecation entry. Add a deprecation describing the previous shape rather than asking editors to click Recover, which discards data.

When should a block be dynamic rather than static?
Whenever its output depends on anything outside its own attributes – a query, an option, the current user, a remote API, or the time. If saved markup could ever be wrong later, it must be dynamic. If the block only renders its own attributes, static is cheaper because it costs nothing at render time.

How do I stop block CSS loading on every page?
Register styles through block.json‘s style and editorStyle fields rather than enqueueing globally. WordPress then loads them only on pages containing the block. Verify it is working – a stray wp_enqueue_style in an init hook silently defeats the whole mechanism.

Can I change an attribute name after launch?
Not directly. Add the new attribute, add a deprecation whose migrate maps the old name to the new one, and keep the old attribute defined until you are confident every instance has been re-saved. Renaming without that path invalidates existing content immediately.

← Back to all insights