LESSON 3 โฑ๏ธ 12 min read

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: In save, use 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 JSXReact hooks (useState, useEffect)
Attribute valuesAPI calls or side effects
Conditional renderingEvent 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.

๐ŸŽฏ Lesson Complete! You understand block serialization and can build proper Save components.