5 real-world deprecation patterns that cover most cases when maintaining custom Gutenberg blocks.
When only the rendered HTML changed but attributes stayed the same. No migrate() needed.
// Current version (v2) - saves <div class="new-wrapper">
save: ({ attributes }) => {
return (
<div className="new-wrapper">
<h2>{ attributes.title }</h2>
</div>
);
},
// Deprecation for v1 - saved <section class="old-wrapper">
deprecated: [
{
attributes: {
title: {
type: 'string',
source: 'html',
selector: 'h2',
},
},
save: ({ attributes }) => {
return (
<section className="old-wrapper">
<h2>{ attributes.title }</h2>
</section>
);
},
// No migrate() needed - attributes didn't change
},
],When you renamed an attribute or changed its structure. Use isEligible() to detect old content and migrate() to rewrite it.
// Current version (v2)
attributes: {
heading: {
type: 'string',
source: 'html',
selector: 'h2',
},
showSubtitle: {
type: 'boolean',
default: false,
},
},
deprecated: [
{
// v1 had 'title' instead of 'heading', and 'subtitle' as string
attributes: {
title: {
type: 'string',
source: 'html',
selector: 'h2',
},
subtitle: {
type: 'string',
source: 'html',
selector: 'p',
},
},
isEligible: (attributes) => {
return attributes.title !== undefined && attributes.heading === undefined;
},
migrate: (attributes) => {
return {
heading: attributes.title,
showSubtitle: attributes.subtitle !== '' && attributes.subtitle !== undefined,
subtitle: attributes.subtitle,
};
},
},
],When you moved an attribute from markup to block comment (or vice versa), or changed the selector. The attributes schema in the deprecation must match what's actually in the database.
// Current version (v2) - reads from block comment
attributes: {
linkUrl: {
type: 'string',
source: 'attribute',
attribute: 'href',
selector: 'a',
},
linkTarget: {
type: 'string',
default: '_self',
},
},
deprecated: [
{
// v1 stored linkUrl in markup with different selector
attributes: {
linkUrl: {
type: 'string',
source: 'attribute',
attribute: 'data-link',
selector: 'div.custom-block',
},
linkTarget: {
type: 'string',
source: 'attribute',
attribute: 'data-target',
selector: 'div.custom-block',
},
},
// No migrate() needed if attribute names stayed the same
// Just need the old save() to reconstruct the old markup
save: ({ attributes }) => {
return (
<div className="custom-block"
data-link={ attributes.linkUrl }
data-target={ attributes.linkTarget }>
<a href={ attributes.linkUrl }>Click</a>
</div>
);
},
},
],When the nesting structure changed. migrate() must remap the inner blocks array.
// Current version (v2) - expects [heading, content] as direct children
save: ({ attributes }) => {
return (
<div className="card-v2">
<InnerBlocks
template={[
['core/heading', { level: 2 }],
['core/paragraph'],
]}
/>
</div>
);
},
deprecated: [
{
// v1 wrapped content in a group block
save: ({ attributes }) => {
return (
<div className="card-v1">
<InnerBlocks
template={[
['core/heading', { level: 2 }],
['core/group', {}, [
['core/paragraph'],
]],
]}
/>
</div>
);
},
migrate: (attributes, innerBlocks) => {
// Find the group block and extract its children
const groupBlock = innerBlocks.find(block => block.name === 'core/group');
if (!groupBlock) return { attributes, innerBlocks };
// Flatten: [heading, group] → [heading, ...group.children]
const newInnerBlocks = innerBlocks.flatMap(block => {
if (block.name === 'core/group') {
return block.innerBlocks;
}
return block;
});
return { attributes, innerBlocks: newInnerBlocks };
},
},
],WordPress tries each deprecation in order. Put the newest old format first to avoid unnecessary checks.
// Current version (v3)
attributes: {
title: { type: 'string' },
subtitle: { type: 'string' },
showAuthor: { type: 'boolean', default: false },
},
deprecated: [
// v2 → v3: renamed 'author' to 'showAuthor'
{
attributes: {
title: { type: 'string' },
subtitle: { type: 'string' },
author: { type: 'boolean' },
},
isEligible: (attrs) => attrs.author !== undefined && attrs.showAuthor === undefined,
migrate: (attrs) => ({
title: attrs.title,
subtitle: attrs.subtitle,
showAuthor: attrs.author,
}),
},
// v1 → v2: added 'subtitle', no 'showAuthor' at all
{
attributes: {
title: { type: 'string' },
},
isEligible: (attrs) => attrs.subtitle === undefined && attrs.author === undefined,
migrate: (attrs) => ({
title: attrs.title,
subtitle: '',
showAuthor: false,
}),
},
// v0 → v1: very old format with different structure
{
attributes: {
heading: { type: 'string' }, // was 'heading' not 'title'
},
isEligible: (attrs) => attrs.heading !== undefined && attrs.title === undefined,
migrate: (attrs) => ({
title: attrs.heading,
subtitle: '',
showAuthor: false,
}),
},
],- Always test each deprecation with real saved content from the database
- Order matters: newest old format first, oldest last
isEligible()is optional but recommended for clarity and performance- If you add a new deprecation later, insert it at the top of the array
- Keep old
save()functions intact - they reconstruct the markup to extract attributes