I’ve already written about the broader checklist for shipping Gutenberg blocks. This post is narrower on purpose: what happens when the HTML your save() produces has to change, and how to do that without lighting up “This block contains unexpected or invalid content” across a huge install base.
If your block saves static markup into the post, this is not optional reading. It’s the difference between a quiet release and a support week.
The failure mode (and why the front end lies to you)
Static blocks serialize HTML into post content. On load, the editor re-runs your current save() and compares it to what’s stored. Mismatch → invalid block.
The front end can still look fine. Merchants don’t open the editor every day. Then someone edits a product page, sees recovery UI, and suddenly your “small cleanup” is a product incident.
I’ve watched this play out at plugin scale. You don’t get to tell 100K site owners to “just re-insert the block.”
Prefer not needing this: dynamic render
If save returns null and PHP renders from attributes, you skip a lot of save-validation pain. That’s still my default for product blocks.
But plenty of real blocks are static — or were static for years — and still need to evolve. Deprecations are how you keep those honest.
Mental model: deprecations are not a database migration chain
WordPress does not run deprecation #1, then feed the result into #2, then #3 like a Laravel migration stack.
Roughly:
- Current
save()fails validation against stored content. - The editor tries each entry in
deprecated(newest first is the usual convention) until one oldsave()matches the stored HTML. - Attributes (and
migrate, if present) from that match are handed back to the current block definition to re-save.
Miss that model and you’ll “add a migrate” on the newest deprecation only, then wonder why older content never upgrades.
What I put in a deprecation object
At minimum, for the version you’re retiring:
- The old
attributesshape (not silently inherited) - The old
save()that still validates against historical HTML - A
migrate()when attributes move, rename, or change meaning - Sometimes
supports/isEligiblewhen the match rules get subtle
I keep each retired version as a named constant in deprecated.js (or similar), then export an array with newest deprecation first. Readable beats clever.
A practical sequence before you ship the breaking markup change
- Freeze the old
save()in a deprecation before you change the current one. - Write
migrate()for any attribute reshaping — test it with real post HTML from staging, not a hand-built fixture only. - Change the current
save()/ edit UI. - Open posts that still contain old serialized blocks. Confirm: no invalid state, attributes look right after update, front end still matches.
- Only then cut the release.
If step 4 is “we’ll see what support says,” you’re not ready.
Migrations people forget
- Renaming an attribute (
buttonText→label) withoutmigrate - Moving inner content into
InnerBlocksand leaving old string attributes behind - Changing wrapper tags or classnames “for BEM cleanliness” with no deprecation
- Tightening
save()output (extra whitespace, attribute order) enough to fail equality checks - Shipping a dynamic (
save: null) rewrite of a formerly static block without a path for old HTML
That last one bites teams who assume “we’re dynamic now, validation is gone.” Old posts still contain the old static HTML until someone opens and updates them.
Editor UX is part of the migration
A technically correct deprecation that leaves the sidebar in a confusing state still fails users. After migrate:
- Defaults should fill gaps
- Deprecated UI controls shouldn’t haunt the inspector
- Preview should match front end closely enough that merchants trust the update
You’re not only migrating data. You’re migrating trust.
When I still choose static save() on purpose
Dynamic isn’t free. Static can be right when:
- Output is truly stable documentation-like markup
- You want a usable HTML fallback if the plugin is deactivated
- The block is simple and you accept the deprecation discipline
Just choose it with eyes open. Static without a deprecation culture is technical debt with a support pager.
Checklist I run on every markup-changing PR
- Old
save()captured indeprecated(newest first) -
migrate()covers attribute renames/moves - Fixture posts with v1 and v2 HTML both upgrade cleanly
- Front end checked before and after opening the editor
- No “Attempt Recovery” on a fresh load of old content
- Release notes mention the content upgrade (one sentence is enough)
Closing
The Interactivity API and server render help you need fewer painful save migrations. They don’t erase the ones you already owe.
If you change what save() prints, you own a deprecation. That’s not ceremony — it’s how Gutenberg content stays editable at scale.