The Save Component & Serialization
How Blocks Save Content
When you save a post, block content is serialized to HTML with special comments:
<!-- wp:theme/testimonial {"rating":5,"mediaId":42} -->
<div class="wp-block-theme-testimonial rating-5">
<img src="avatar.jpg" class="testimonial-avatar" />
<blockquote class="testimonial-content">Great product!</blockquote>
<cite>John Doe</cite>
</div>
<!-- /wp:theme/testimonial -->The block comment stores:
- Block name (
wp:theme/testimonial) - Attributes as JSON
- Closing comment
The Save Component
Save returns the static HTML that gets stored:
// src/save.js
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const { content, author, rating, mediaUrl } = attributes;
const blockProps = useBlockProps.save( {
className: `rating-${ rating }`,
} );
const stars = 'โ
'.repeat( rating ) + 'โ'.repeat( 5 - rating );
return (
<div { ...blockProps }>
{ mediaUrl && (
<img
src={ mediaUrl }
alt=""
className="testimonial-avatar"
/>
) }
<div className="testimonial-body">
<RichText.Content
tagName="blockquote"
className="testimonial-content"
value={ content }
/>
<div className="testimonial-rating">{ stars }</div>
<RichText.Content
tagName="cite"
value={ author }
/>
</div>
</div>
);
}RichText.Content instead of RichText. It's a static renderer, not an editor.
Save Rules
The Save component has strict rules:
| โ Allowed | โ Not Allowed |
|---|---|
| Static JSX | React hooks (useState, useEffect) |
| Attribute values | API calls or side effects |
| Conditional rendering | Event handlers |
| useBlockProps.save() | Browser APIs |
// โ WRONG - No hooks in save
export default function save( { attributes } ) {
const [state, setState] = useState(); // Error!
return <div>...</div>;
}
// โ
CORRECT - Pure function
export default function save( { attributes } ) {
return <div>{ attributes.title }</div>;
}Attribute Sources
Attributes can be extracted from the saved HTML:
// block.json
"attributes": {
"content": {
"type": "string",
"source": "html",
"selector": ".testimonial-content"
},
"mediaUrl": {
"type": "string",
"source": "attribute",
"selector": "img",
"attribute": "src"
},
"items": {
"type": "array",
"source": "query",
"selector": ".item",
"query": {
"title": {
"type": "string",
"source": "html",
"selector": ".item-title"
}
}
}
}This means the saved HTML IS the source of truth. WordPress extracts these values when loading.
Attributes in JSON vs HTML
Attributes can live in two places:
In block comment (no source):
"rating": {
"type": "number",
"default": 5
}Stored as:
In saved HTML (with source):
"content": {
"type": "string",
"source": "html",
"selector": "p"
}Extracted from: Content here
Block Validation
WordPress validates that saved HTML matches what Save produces. Mismatches cause errors:
Block validation failed for 'theme/testimonial'
Content: <div class="old-class">...</div>
Expected: <div class="new-class">...</div>Common causes:
- Changing Save without deprecation
- Dynamic content in Save
- Missing attributes
Handling Validation Errors
Use deprecations when changing block structure:
// src/index.js
import { registerBlockType } from '@wordpress/blocks';
import metadata from './block.json';
import Edit from './edit';
import save from './save';
// Old save function (before changes)
const v1Save = ( { attributes } ) => {
return (
<div className="wp-block-theme-testimonial">
<p>{ attributes.content }</p>
</div>
);
};
registerBlockType( metadata.name, {
edit: Edit,
save,
deprecated: [
{
attributes: {
content: { type: 'string' }
},
save: v1Save,
migrate( attributes ) {
return {
...attributes,
// Transform old attributes to new
};
}
}
]
} );Returning null (Dynamic Blocks)
For blocks rendered by PHP (dynamic blocks), save can return null:
export default function save() {
return null;
}This stores only the block comment:
<!-- wp:theme/posts-grid {"count":6} /-->We'll cover dynamic blocks in Lesson 5.
Frontend Styles
// src/style.scss
.wp-block-theme-testimonial {
display: flex;
gap: 20px;
padding: 24px;
background: #f8f9fa;
border-radius: 8px;
margin: 24px 0;
.testimonial-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.testimonial-content {
font-size: 18px;
font-style: italic;
margin: 0 0 12px;
&::before { content: '"'; }
&::after { content: '"'; }
}
.testimonial-rating {
color: #f59e0b;
font-size: 18px;
margin-bottom: 8px;
}
cite {
font-style: normal;
font-weight: 600;
}
}Next Steps
In the next lesson, we'll add InspectorControls for sidebar settings and BlockControls for toolbar options.