All articles
WordPress

Gutenberg block deprecations without breaking existing content

August 21, 20269 min read

A static Gutenberg block is a content format, not just a React component. Once users save it in posts, the HTML produced by that release becomes data your plugin must continue to understand.

Changing a wrapper from <div> to <aside>, renaming a class, moving text into InnerBlocks, or changing an attribute source can invalidate every existing instance. Gutenberg's Deprecation API gives old content a controlled path to the current format, but only when each historical version is treated as an independent snapshot.

This is the upgrade discipline I use for block products such as Ultimate Blocks. If you are diagnosing an existing warning first, use the companion guide to Gutenberg block validation errors.

What a block deprecation actually does

When current save() output cannot validate stored content, Gutenberg tries entries in the block's deprecated array. A matching entry supplies the old attribute schema and old save() implementation. Its optional migrate() function converts the parsed attributes and inner blocks into the current shape.

Stored block markup
       │
       ├── validates against current save() ──> use current block
       │
       └── invalid
             │
             ├── matches newest deprecation ──> migrate to current shape
             ├── matches older deprecation  ──> migrate to current shape
             └── no match ────────────────────> invalid block warning

A critical detail: deprecations do not run as a chain. Gutenberg does not migrate version one to version two and then version two to version three. Every historical entry must be able to migrate its matching content directly to the current format.

Build the first deprecation as a snapshot

Assume version one of a notice block saved this markup:

// deprecated/v1/save.tsx
import { RichText, useBlockProps } from "@wordpress/block-editor";

export function save({ attributes }) {
  return (
    <div {...useBlockProps.save({ className: "notice" })}>
      <RichText.Content tagName="p" value={attributes.text} />
    </div>
  );
}

The current version renames text to content, adds a tone, and saves an <aside>. Preserve the complete old contract:

// deprecated/v1/index.ts
import { save } from "./save";

const attributes = {
  text: {
    type: "string",
    source: "html",
    selector: "p",
  },
};

export default {
  attributes,
  save,
  migrate(oldAttributes) {
    return {
      content: oldAttributes.text,
      tone: "info",
    };
  },
};

Register it with the current block settings:

import metadata from "./block.json";
import { registerBlockType } from "@wordpress/blocks";
import { edit } from "./edit";
import { save } from "./save";
import v1 from "./deprecated/v1";

registerBlockType(metadata.name, {
  edit,
  save,
  deprecated: [v1],
});

The old save() must reproduce the old serialized markup, not an approximation of how you remember it. Copy it from the released tag whenever possible.

Keep dependencies inside each historical version

Importing a shared helper into a deprecation is convenient and dangerous. If that helper changes next month, the historical save() output changes with it and the deprecation can stop recognizing content it previously handled.

// Risky: this shared helper can evolve with the current block.
import { noticeClassName } from "../../utils/notice-class-name";

// Safer: freeze the behavior needed by this historical format.
function v1ClassName() {
  return "notice";
}

Treat a deprecation directory like an immutable compatibility layer. Duplicate a small helper when necessary rather than coupling old formats to current application logic.

Order entries newest first

WordPress recommends reverse chronological order because Gutenberg tests deprecations in array order. The newest historical format is usually the most common and should be attempted first.

import v3 from "./deprecated/v3";
import v2 from "./deprecated/v2";
import v1 from "./deprecated/v1";

export const deprecated = [v3, v2, v1];

Do not assume a successful match on an old entry will inherit migration changes added to a newer one. If the current attribute shape changes again, review the migrate() function in every deprecation that can still match installed content.

Use isEligible() only for migrations validation cannot detect

Normally, Gutenberg tries a deprecation when current markup is invalid. Sometimes the old and current markup are both valid, but the stored attributes still need normalization. An isEligible() function can opt matching content into migration even when validation alone would not select it.

export default {
  attributes,
  save,
  isEligible(attributes) {
    return attributes.alignment === "centre";
  },
  migrate(attributes) {
    return {
      ...attributes,
      alignment: "center",
    };
  },
};

Keep this predicate narrow and deterministic. A broad isEligible() can make current content repeatedly enter a migration it does not need.

Migrating attributes and InnerBlocks

When only attributes change, migrate() returns the new attribute object. When the migration also creates or changes inner blocks, return a tuple containing attributes and the new inner-block array.

import { createBlock } from "@wordpress/blocks";

migrate(attributes, innerBlocks) {
  const { heading, ...nextAttributes } = attributes;

  return [
    nextAttributes,
    [
      createBlock("core/heading", {
        content: heading,
        level: 3,
      }),
      ...innerBlocks,
    ],
  ];
}

This is a data migration. Preserve user content and ordering explicitly, and make sure the current save() or dynamic renderer understands the resulting inner-block structure.

When not to use a deprecation

Not every change needs one:

  • Editor-only UI changes do not need a deprecation if saved attributes and markup remain compatible.
  • CSS can often change visual presentation without changing stored HTML.
  • Dynamic frontend markup can evolve in render.php because it is generated at request time.
  • A fundamentally different block may deserve a new block name and an explicit transform rather than a permanent compatibility branch inside the old block.

Avoid changing serialized HTML for cosmetic reasons. Every unnecessary markup revision increases the compatibility surface you must test for the lifetime of the plugin.

Test every released fixture

Keep one fixture per public serialized format:

fixtures/
├── v1.html
├── v2.html
└── v3.html

For every fixture, verify that:

  1. The editor loads it without an invalid-content warning.
  2. Attributes and inner blocks parse without losing user content.
  3. Saving converts it to the current serialized format.
  4. Reopening the migrated post remains valid.
  5. The frontend before and after migration preserves the intended meaning.

Fixtures should come from actual posts created with released plugin builds. Also test a page containing several versions together; production content rarely upgrades one isolated block at a time.

Before publishing, install the previous plugin release, create representative content, upgrade to the candidate build, and edit and save that content. Unit fixtures catch serialization regressions, while this upgrade test catches registration, asset, and editor-runtime failures.

A release rule that prevents most breakage

Treat save() and sourced attributes as a public API. Before merging any change to either one, answer three questions:

  1. Does the new implementation regenerate markup identical to the latest released version?
  2. If not, is the markup change necessary rather than merely convenient?
  3. Does every historical fixture migrate directly to the new current format?

That review is cheaper than repairing hundreds of invalid blocks after release. It also scales naturally into a multi-block production architecture, where compatibility fixtures become part of each block's release contract.

Official references

Continue exploring

Use the block validation debugging guide when you need to identify the failing historical markup. The free and Pro plugin architecture article covers release boundaries around a growing block product. For compatibility work across an existing plugin, see Gutenberg block development.


Farhan Shafi
Farhan Shafi
WordPress Product Engineer · Full-Stack Developer

Related articles