Static and Dynamic Blocks

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

A block reaches the front end in one of two ways. A static block is rendered once, in the browser, when the author saves: its save function produces HTML that is stored in the post content and printed as it is on every request. A dynamic block stores only its name and attributes, and PHP produces the HTML on every request. This page is for plugin and block developers: it writes the edit and save functions of the Lumen Press block lumen/book-card, the render.php of lumen/latest-books, and a hybrid block, lumen/series-shelf, that saves its inner blocks and renders the rest on the server. It then breaks the static block on purpose to show what block validation does.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, @wordpress/scripts 37.0.0 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; nothing on this page changes in it so far. The three blocks were built with a real wp-scripts build, the stored markup and the rendered HTML below come from a fresh 7.1.3 site with the lumen-catalog plugin active (WP-CLI and the built-in PHP server in Docker), and the validation messages come from the block parser and validator of @wordpress/blocks 15.27.0 from npm (WordPress 7.1.3 itself bundles 15.24.0, from its Gutenberg build), run in Node against the built index.js files.

The block.json files of the two Lumen blocks, the build layout and the registration with a metadata collection are in block.json and Registration; Node, @wordpress/create-block and wp-env are in Block Development Environment. Attribute sources, supports and InnerBlocks options are in Attributes, Supports and InnerBlocks, and deprecated entries in Variations, Styles, Transforms and Deprecations.

From the editor to the page

The editor keeps the blocks of a post in memory as a tree of objects (name, attributes, inner blocks). On save it serializes the tree into the post_content column as HTML wrapped in block delimiters, the HTML comments that start with wp:. On a front-end request, do_blocks() (hooked to the_content, and run by block themes on templates) parses that text back into blocks and renders each one. What happens next depends on the block type:

flowchart LR subgraph Editor["Block editor (browser)"] E["edit() renders the editing UI"] --> A["attributes in the block tree"] A --> S["save() returns HTML, or null"] end subgraph DB["post_content (database)"] ST["static: delimiter + JSON attributes + saved HTML"] DY["dynamic: delimiter + JSON attributes only"] end subgraph Front["Front-end request (PHP)"] P["do_blocks() parses the delimiters"] P --> RS["static: print the saved HTML"] P --> RD["dynamic: render_callback or render.php builds HTML"] RS --> F["render_block filters, then the page"] RD --> F end S --> ST S --> DY ST --> P DY --> P ST -. "on next edit: parse, re-run save(), compare" .-> E

Two consequences follow from the diagram. The HTML of a static block is frozen at save time, so data that changes later (a new book, a corrected price) is not reflected until someone edits and saves the post again. And because the editor re-runs save() when it opens the post and compares the result with what is stored, changing a static block’s save() later makes old content look invalid. Dynamic blocks have neither problem, at the cost of PHP work on every request.

The example blocks

The three blocks live in the lumen-catalog plugin under src/blocks/, are built to build/blocks/ by the plugin’s build script, wp-scripts build --source-path=src/blocks --output-path=build/blocks --blocks-manifest --experimental-modules (@wordpress/scripts pinned to 37.0.0), and are registered from Lumen\Catalog\Blocks::boot() with wp_register_block_types_from_metadata_collection() (6.8). All use apiVersion 3 and the lumen-catalog category; their block.json fields are explained in block.json and Registration. book-card/ holds block.json, index.js, edit.js, save.js and two stylesheets; latest-books/ and series-shelf/ hold at least block.json, index.js and render.php. After the build, WP_Block_Type::is_dynamic(), which is true when a block type has a render callback, returns false for lumen/book-card and true for the other two (checked with wp eval against the registry).

edit and save

registerBlockType() attaches two functions to the metadata that block.json describes:

Function Runs Receives and returns

edit

In the editor, every time the block re-renders (a React component).

Props: attributes, setAttributes, isSelected, clientId, context (the values named in usesContext) and others. Returns the editing UI: what the author sees on the canvas plus toolbar and sidebar controls.

save

In the editor, when the post is serialized, and again when the post is loaded, to validate the stored HTML.

Props: attributes (and innerBlocks). Returns the element tree that becomes the stored HTML, or null. Optional in registerBlockType(): when it is left out, the default saves no markup, which is what a dynamic block wants.

setAttributes() takes an object with the attributes to change; since WordPress 6.9 it also accepts an updater function, setAttributes( ( current ) => ( { count: current.count + 1 } ) ), which must be pure. Attributes are immutable: build a new array or object instead of changing the current one in place.

useBlockProps

The outermost element of both edit and save must receive the block wrapper props. In edit, the useBlockProps() hook returns the class name derived from the block name (wp-block-lumen-book-card), the classes and inline styles that block supports produce (alignwide, has-background, has-base-background-color, padding), the id from the anchor support, and the editor’s own props: the ref, data-block, data-type, tabIndex and the ARIA attributes that make selection, keyboard navigation and drag and drop work. In save, useBlockProps.save() returns only the parts that belong in the stored HTML (class names, styles, id). Extra props are passed as an argument and merged: useBlockProps( { className: 'is-featured' } ).

With apiVersion 3 the editor relies on these props to find the block’s DOM node, and since WordPress 7.1 the post editor is always iframed, so a block that renders its own wrapper without them cannot be selected properly and loses its supports styles. useInnerBlocksProps() plays the same role for a block whose wrapper is also the container of its inner blocks.

RichText and RichText.Content

RichText is the editor’s editable text field (a contenteditable element with formatting, paste handling and the format toolbar); RichText.Content is its save counterpart. The card’s title is declared with the rich-text type and source and the selector .wp-block-lumen-book-card__title, so it is read back from the saved <h3>.

edit.js of lumen/book-card:

import { __ } from '@wordpress/i18n';
import {
	InnerBlocks,
	InspectorControls,
	RichText,
	useBlockProps,
} from '@wordpress/block-editor';
import { PanelBody, TextControl, ToggleControl } from '@wordpress/components';

export default function Edit( { attributes, setAttributes } ) {
	const { title, isbn, coverUrl, showIsbn } = attributes;

	return (
		<>
			<InspectorControls>
				<PanelBody title={ __( 'Book', 'lumen-catalog' ) }>
					<TextControl
						__next40pxDefaultSize
						label={ __( 'ISBN', 'lumen-catalog' ) }
						value={ isbn }
						onChange={ ( value ) =>
							setAttributes( { isbn: value } )
						}
					/>
					<ToggleControl
						label={ __( 'Show ISBN', 'lumen-catalog' ) }
						checked={ showIsbn }
						onChange={ ( value ) =>
							setAttributes( { showIsbn: value } )
						}
					/>
				</PanelBody>
			</InspectorControls>
			<article { ...useBlockProps() }>
				{ coverUrl && <img src={ coverUrl } alt="" /> }
				<RichText
					tagName="h3"
					className="wp-block-lumen-book-card__title"
					value={ title }
					onChange={ ( value ) => setAttributes( { title: value } ) }
					placeholder={ __( 'Book title', 'lumen-catalog' ) }
				/>
				{ showIsbn && isbn && (
					<p className="wp-block-lumen-book-card__isbn">{ isbn }</p>
				) }
				<InnerBlocks
					allowedBlocks={ [ 'core/paragraph' ] }
					template={ [ [ 'core/paragraph' ] ] }
				/>
			</article>
		</>
	);
}

save.js mirrors the visible part of edit, with the .save() and .Content counterparts and no controls:

import { InnerBlocks, RichText, useBlockProps } from '@wordpress/block-editor';

export default function save( { attributes } ) {
	const { title, isbn, coverUrl, showIsbn } = attributes;

	return (
		<article { ...useBlockProps.save() }>
			{ coverUrl && <img src={ coverUrl } alt="" /> }
			<RichText.Content
				tagName="h3"
				className="wp-block-lumen-book-card__title"
				value={ title }
			/>
			{ showIsbn && isbn && (
				<p className="wp-block-lumen-book-card__isbn">{ isbn }</p>
			) }
			<InnerBlocks.Content />
		</article>
	);
}

index.js imports both, with block.json as metadata and the two stylesheets, and calls registerBlockType( metadata.name, { edit: Edit, save } ).

What save must not do

save must be a pure function of its attributes. It cannot use React state or effects, read a data store (select( 'core' )), call Date.now(), fetch anything or depend on the current user; otherwise the HTML it produces when the post is reopened differs from the stored HTML and the block is reported as invalid. Data that comes from elsewhere is either copied into an attribute by edit (as the card copies the ISBN) or rendered on the server by a dynamic block. A string returned from save is escaped, so markup in it is shown as text; RawHTML from @wordpress/element prints raw HTML but bypasses the element tree’s escaping and is discouraged.

Serialized markup

With the card inserted, the title typed, the ISBN filled in the sidebar and one paragraph added inside it, the editor saves this to post_content (produced by serialize() from @wordpress/blocks with the built block, and stored with wp post create):

<!-- wp:lumen/book-card {"isbn":"978-1-23456-789-7"} -->
<article class="wp-block-lumen-book-card"><img src="/wp-content/uploads/lighthouse-ledger.jpg" alt=""/><h3 class="wp-block-lumen-book-card__title">The Lighthouse Ledger</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-789-7</p><!-- wp:paragraph -->
<p>A keeper, a storm and a logbook that does not add up.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->

The delimiter’s JSON holds only the attributes that are not sourced from the HTML and differ from their defaults: isbn is there, showIsbn (default true) is not, and title and coverUrl are read back from the <h3> and the src of the <img>. The inner paragraph is a complete block of its own, nested inside the card’s HTML where InnerBlocks.Content placed it. On the server, parse_blocks() shows the same structure:

$ wp eval '$b = parse_blocks( get_post( 7 )->post_content );
  foreach ( $b as $x ) if ( $x["blockName"] ) printf( "%s attrs=%s innerHTML=%d chars innerBlocks=%d\n",
    $x["blockName"], wp_json_encode( $x["attrs"] ), strlen( $x["innerHTML"] ), count( $x["innerBlocks"] ) );'
lumen/book-card attrs={"isbn":"978-1-23456-789-7"} innerHTML=248 chars innerBlocks=1
lumen/latest-books attrs={"count":2,"genre":"mystery"} innerHTML=0 chars innerBlocks=0
lumen/series-shelf attrs={"series":"lighthouse-mysteries"} innerHTML=4 chars innerBlocks=2

The server does not know the sourced attributes: PHP parses only the JSON in the delimiter, so title exists only in the HTML. A dynamic block that needs a value in PHP must store it in the delimiter (an attribute without source).

Block validation

When the editor opens a post, it parses every block, rebuilds the attributes (from the delimiter and the sources), calls the block’s current save() with them and compares the result with the stored HTML. The comparison is semantic, not byte for byte: whitespace between tags, the order of HTML attributes and of class names, and self-closing syntax do not matter. A different tag, an extra or missing HTML attribute, or different text does.

To see it fail, the card’s title was changed from <h3> to <h2> in save.js (and in edit.js), the plugin was rebuilt, and the post above was parsed with the new build. The validator reports:

Block validation: Expected tag name `h2`, instead saw `h3`.
Content generated by `save` function:
<article class="wp-block-lumen-book-card"><img src="/wp-content/uploads/lighthouse-ledger.jpg" alt=""/><h2 class="wp-block-lumen-book-card__title">The Lighthouse Ledger</h2><p class="wp-block-lumen-book-card__isbn">978-1-23456-789-7</p></article>
Content retrieved from post body:
<article class="wp-block-lumen-book-card"><img src="/wp-content/uploads/lighthouse-ledger.jpg" alt=""/><h3 class="wp-block-lumen-book-card__title">The Lighthouse Ledger</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-789-7</p></article>
lumen/book-card: INVALID

Inner blocks are left out of the comparison (they are validated on their own), which is why the paragraph does not appear in either version. The same messages appear in the browser console as Block validation: … warnings; the last line is the summary of the parser’s isValid flag. Two more changes were tried against the original build: adding a data-x="1" attribute to the stored <article> made the block invalid ("Expected attributes"), and editing the ISBN in the HTML without changing the delimiter made it invalid ("Expected text"), because save() rebuilds the paragraph from the delimiter’s isbn. Swapping the order of the <img> attributes and adding whitespace left it valid.

In the editor, an invalid block shows "Block contains unexpected or invalid content." with an Attempt Block Recovery button, which re-creates the block from its parsed attributes (dropping anything save does not produce). The block’s options menu adds Resolve (a side-by-side comparison offering Convert to HTML or Convert to Blocks) and Convert to Classic Block. Authors should never have to do this after a plugin update.

The fix for an intentional markup change is a deprecated entry that keeps the old save (and the old attributes and supports) so the editor can recognize old content and migrate it. With the original save kept as save-v1.js:

// index.js: the imports of the original file, plus the old save function.
import saveV1 from './save-v1';

registerBlockType( metadata.name, {
	edit: Edit,
	save,
	deprecated: [
		{
			attributes: metadata.attributes,
			supports: metadata.supports,
			save: saveV1,
		},
	],
} );

Parsed with this build, the same post gives Updated Block: lumen/book-card and lumen/book-card: valid: the editor matched the old save, kept the attributes and will write the <h2> version on the next save. Until each post is re-saved, the front end keeps printing the old <h3>, because nothing re-renders static HTML on the server. Ordering, migrate, isEligible and multiple deprecations are covered in Variations, Styles, Transforms and Deprecations.

Validation applies only to blocks that save HTML. A dynamic block saves an empty body (or only its inner blocks) and cannot fail it, whatever render.php outputs.

Dynamic blocks and render.php

A block becomes dynamic when its type has a render callback. There are two ways to give it one:

  • "render": "file:./render.php" in block.json (WordPress 6.1+). register_block_type() wraps the file in a closure that runs ob_start(), loads the file with require and returns the buffer, so the file prints HTML and its variables are local to the closure.

  • render_callback in the arguments of register_block_type(): a PHP callable with the signature ( array $attributes, string $content, WP_Block $block ): string. PHP-only blocks (7.0) use this form; see block.json and Registration.

Both receive the same three values:

Variable Content

$attributes

The block’s attributes from the delimiter’s JSON, validated against the attributes schema of block.json, with defaults applied. For <!-- wp:lumen/latest-books {"count":2} /-->, var_dump() shows count 2, genre "" and showDate true. Sourced attributes are not available.

$content

The block’s saved inner HTML with its inner blocks already rendered: empty for a block that saves nothing, the rendered inner blocks for a hybrid block.

$block

The WP_Block instance: $block->name, $block->attributes, $block->context (the values listed in usesContext, such as postId and postType inside a Query Loop or on a single template), $block->parsed_block, $block->inner_blocks and $block->block_type.

get_block_wrapper_attributes()

get_block_wrapper_attributes() is the server-side useBlockProps.save(). It returns, as an escaped string of HTML attributes, the block’s class name, the classes and inline styles generated from the block supports and the values the author chose (colors, padding, alignment, anchor), merged with an optional array of extra attributes (class and style are concatenated, others overridden). It reads the block currently being rendered, so it only works inside a render callback. Rendering lumen/latest-books with a wide alignment, a background color and top padding:

$ wp eval 'echo do_blocks( "<!-- wp:lumen/latest-books {\"count\":1,\"showDate\":false,\"align\":\"wide\",\"backgroundColor\":\"base\",\"style\":{\"spacing\":{\"padding\":{\"top\":\"1rem\"}}}} /-->" );'
<ul style="padding-top:1rem" class="alignwide wp-block-lumen-latest-books has-background has-base-background-color">
			<li>
			<a href="https://lumenpress.test/books/the-glass-orchard/">The Glass Orchard</a>
					</li>
	</ul>

A wrapper printed without it renders, but every color, spacing and alignment setting the author picks in the sidebar is silently ignored on the front end.

render.php of lumen/latest-books

The block lists the most recent published books, optionally limited to one genre, and leaves the current book out when it is placed on a single book template. The query is a WP_Query over lp_book. get_posts() would produce the same list, but it sets suppress_filters to true, so query filters added by other plugins (multilingual plugins, for example) would not run:

<?php
/**
 * Server-side rendering of lumen/latest-books.
 *
 * @package Lumen\Catalog
 *
 * @var array    $attributes Block attributes, with defaults applied.
 * @var string   $content    Saved inner content (empty: the block has no inner blocks).
 * @var WP_Block $block      Block instance, with $block->context.
 */

defined( 'ABSPATH' ) || exit;

$lumen_args = array(
	'post_type'           => 'lp_book',
	'post_status'         => 'publish',
	'posts_per_page'      => max( 1, min( 12, (int) $attributes['count'] ) ),
	'no_found_rows'       => true,
	'ignore_sticky_posts' => true,
);

// On a single book, leave the current book out of the list.
if ( 'lp_book' === ( $block->context['postType'] ?? '' ) && ! empty( $block->context['postId'] ) ) {
	$lumen_args['post__not_in'] = array( (int) $block->context['postId'] );
}

if ( '' !== $attributes['genre'] ) {
	$lumen_args['tax_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
		array(
			'taxonomy' => 'lp_genre',
			'field'    => 'slug',
			'terms'    => sanitize_key( $attributes['genre'] ),
		),
	);
}

$lumen_query = new WP_Query( $lumen_args );

if ( ! $lumen_query->have_posts() ) {
	return;
}
?>
<ul <?php echo get_block_wrapper_attributes(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
	<?php foreach ( $lumen_query->posts as $lumen_book ) : ?>
		<li>
			<a href="<?php echo esc_url( get_permalink( $lumen_book ) ); ?>"><?php echo esc_html( get_the_title( $lumen_book ) ); ?></a>
			<?php if ( $attributes['showDate'] ) : ?>
				<time datetime="<?php echo esc_attr( get_the_date( 'c', $lumen_book ) ); ?>"><?php echo esc_html( get_the_date( '', $lumen_book ) ); ?></time>
			<?php endif; ?>
		</li>
	<?php endforeach; ?>
</ul>

Returning early prints nothing, and an empty render removes the block from the page. no_found_rows skips the SQL_CALC_FOUND_ROWS count that pagination would need. Iterating over $lumen_query->posts instead of calling the_post() leaves the global $post alone, so the surrounding template (and blocks after this one) still see the page’s own post; a loop that calls the_post() must end with wp_reset_postdata(). The lumen_ prefix on the variables keeps the WordPress Coding Standards global-prefix rule satisfied.

The editor side of a dynamic block

The editor still needs an edit function. lumen/latest-books shows the server’s output in the canvas with the ServerSideRender component, which requests /wp/v2/block-renderer/lumen/latest-books from the REST API with the current attributes, and it registers no save, so the default (no markup) applies. Its index.js, shown here without the InspectorControls panel (a RangeControl for count and a ToggleControl for showDate, written like the book card’s panel above):

import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import ServerSideRender from '@wordpress/server-side-render';
import metadata from './block.json';

function Edit( { attributes } ) {
	return (
		<div { ...useBlockProps() }>
			<ServerSideRender
				block={ metadata.name }
				attributes={ attributes }
			/>
		</div>
	);
}

registerBlockType( metadata.name, {
	edit: Edit,
} );

The package’s own documentation calls ServerSideRender a fallback or legacy mechanism: every attribute change costs a REST round trip, and the preview is static HTML. It is acceptable for a simple list like this one. A block with richer editing should render the preview in JavaScript from REST data (useEntityRecords( 'postType', 'lp_book' ) from @wordpress/core-data), as core’s Latest Posts block does.

The editor saves the dynamic block as a single self-closing delimiter, <!-- wp:lumen/latest-books {"count":2,"genre":"mystery"} /-->, and the front end of the page receives (WordPress at https://lumenpress.test/, books "The Lighthouse Ledger" and "The Quiet Lighthouse" in the mystery genre):

<ul class="wp-block-lumen-latest-books">
			<li>
			<a href="https://lumenpress.test/books/the-quiet-lighthouse/">The Quiet Lighthouse</a>
							<time datetime="2026-10-06T09:00:00+00:00">October 6, 2026</time>
					</li>
			<li>
			<a href="https://lumenpress.test/books/the-lighthouse-ledger/">The Lighthouse Ledger</a>
							<time datetime="2026-09-01T09:00:00+00:00">September 1, 2026</time>
					</li>
	</ul>

Adding a third book to the genre with wp post term set 6 lp_genre mystery changes the list on the next request (The Glass Orchard, then The Quiet Lighthouse), without anyone opening the page in the editor. A static block holding the same list would still show the old two books.

Hybrid blocks: saving InnerBlocks.Content

A dynamic block can still contain blocks that the author edits. Their markup must be saved, because PHP cannot rebuild what the author typed, so the block’s save returns <InnerBlocks.Content /> and nothing else; render.php then wraps $content (the rendered inner blocks) and adds the server-generated part. lumen/series-shelf lets the editor write a heading and an introduction, and lists the books of one lp_series term below them. Its block.json declares one attribute, series (string, default ""), the supports html: false, align: [ "wide" ] and color, and "render": "file:./render.php". index.js:

import { registerBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import {
	InnerBlocks,
	InspectorControls,
	useBlockProps,
} from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
import metadata from './block.json';

const TEMPLATE = [
	[
		'core/heading',
		{ level: 2, placeholder: __( 'Series name', 'lumen-catalog' ) },
	],
	[
		'core/paragraph',
		{ placeholder: __( 'Introduce the series', 'lumen-catalog' ) },
	],
];

function Edit( { attributes, setAttributes } ) {
	return (
		<section { ...useBlockProps() }>
			<InspectorControls>
				<PanelBody title={ __( 'Series', 'lumen-catalog' ) }>
					<TextControl
						__next40pxDefaultSize
						label={ __( 'Series slug', 'lumen-catalog' ) }
						value={ attributes.series }
						onChange={ ( value ) =>
							setAttributes( { series: value } )
						}
					/>
				</PanelBody>
			</InspectorControls>
			<InnerBlocks
				allowedBlocks={ [ 'core/heading', 'core/paragraph' ] }
				template={ TEMPLATE }
			/>
			<p className="wp-block-lumen-series-shelf__placeholder">
				{ __(
					'The books of the series are listed here on the site.',
					'lumen-catalog'
				) }
			</p>
		</section>
	);
}

registerBlockType( metadata.name, {
	edit: Edit,
	// Only the inner blocks are saved; render.php adds the wrapper and the list.
	save: () => <InnerBlocks.Content />,
} );

render.php prints the wrapper, $content and the list:

<?php
/**
 * Server-side rendering of lumen/series-shelf.
 *
 * @package Lumen\Catalog
 *
 * @var array    $attributes Block attributes.
 * @var string   $content    Rendered inner blocks (the heading and the introduction).
 * @var WP_Block $block      Block instance.
 */

defined( 'ABSPATH' ) || exit;

$lumen_books = array();

if ( '' !== $attributes['series'] ) {
	$lumen_query = new WP_Query(
		array(
			'post_type'      => 'lp_book',
			'post_status'    => 'publish',
			'posts_per_page' => 20,
			'no_found_rows'  => true,
			'orderby'        => 'date',
			'order'          => 'ASC',
			'tax_query'      => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
				array(
					'taxonomy' => 'lp_series',
					'field'    => 'slug',
					'terms'    => sanitize_key( $attributes['series'] ),
				),
			),
		)
	);
	$lumen_books = $lumen_query->posts;
}
?>
<section <?php echo get_block_wrapper_attributes(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
	<?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already rendered blocks. ?>
	<?php if ( $lumen_books ) : ?>
		<ol class="wp-block-lumen-series-shelf__books">
			<?php foreach ( $lumen_books as $lumen_book ) : ?>
				<li><a href="<?php echo esc_url( get_permalink( $lumen_book ) ); ?>"><?php echo esc_html( get_the_title( $lumen_book ) ); ?></a></li>
			<?php endforeach; ?>
		</ol>
	<?php endif; ?>
</section>

$content is printed without escaping because it is the output of the inner blocks' own rendering, already filtered when the post was saved; escaping it would print the HTML as text. The stored markup has no wrapper of its own, only the inner blocks between the delimiters:

<!-- wp:lumen/series-shelf {"series":"lighthouse-mysteries"} -->
<!-- wp:heading -->
<h2 class="wp-block-heading">Lighthouse Mysteries</h2>
<!-- /wp:heading -->

<!-- wp:paragraph -->
<p>Every book of the series, in publication order.</p>
<!-- /wp:paragraph -->
<!-- /wp:lumen/series-shelf -->

and is served as (blank lines removed):

<section class="wp-block-lumen-series-shelf">
<h2 class="wp-block-heading">Lighthouse Mysteries</h2>
<p class="wp-block-paragraph">Every book of the series, in publication order.</p>
			<ol class="wp-block-lumen-series-shelf__books">
							<li><a href="https://lumenpress.test/books/the-lighthouse-ledger/">The Lighthouse Ledger</a></li>
							<li><a href="https://lumenpress.test/books/the-quiet-lighthouse/">The Quiet Lighthouse</a></li>
					</ol>
	</section>

The paragraph is stored as a bare <p> and served with class="wp-block-paragraph": since WordPress 7.0 a render_block_core/paragraph filter adds the class at render time with WP_HTML_Tag_Processor, an example of server-side processing that also applies to static HTML. A useful side effect of saving the inner blocks shows when the plugin is deactivated. the_content of the same page then prints the book card’s stored HTML unchanged, prints nothing for lumen/latest-books, and prints the series heading and introduction without the wrapper and the list: content the author wrote survives, server-generated content disappears. The handbook makes the same point for save in general: in a dynamic block it may return a fallback copy that is shown only if the plugin is ever disabled.

Static output is not always printed verbatim

WP_Block::render() passes every block, static or dynamic, through the render_block and render_block_{$name} filters; core uses them for server-side block supports (layout classes, the wp-block-paragraph class), Block Bindings and Block Hooks, and the_content adds more (wp_filter_content_tags() put the decoding="async" on the card’s <img>). A plugin can use the same filters and WP_HTML_Tag_Processor to adjust a static block’s output without touching the stored markup or causing validation errors, but not to replace a dynamic block when content depends on data.

Choosing static, dynamic or hybrid

Question Static Dynamic Hybrid

Where is the HTML produced?

In the browser, by save(), once per save

In PHP, on every request

Inner blocks: browser; rest: PHP

Does it reflect data that changes later (new posts, meta, options)?

No, until re-saved

Yes

Yes, for the PHP part

What happens when the markup changes in a new version?

Old content is invalid without a deprecated entry

Nothing; all pages change at once

Only the inner blocks' own markup is validated

What is left if the plugin is deactivated?

The stored HTML

Nothing

The inner blocks

Front-end cost

None beyond parsing

The render callback’s queries; cache them if they are heavy

As dynamic

Content visible to other consumers of post_content (search, exports, REST content.raw)

Yes

Only the delimiter

The inner blocks

Lumen example

lumen/book-card

lumen/latest-books

lumen/series-shelf

A reasonable default: static for content the author writes, dynamic for anything derived from other data (lists, counts, the current user or date) or whose markup is likely to change across releases, hybrid when both are needed. Blocks that also need front-end interactivity stay server-rendered and add the Interactivity API.

Errors and pitfalls

Symptom Cause and fix

"Block contains unexpected or invalid content." on every existing card after an update

save() changed. Add the previous save (with its attributes and supports) to deprecated, or move the block to server rendering.

A block becomes invalid only when certain users edit it

KSES removed attributes or tags from the saved HTML for users without unfiltered_html (Authors, Contributors). Make save() produce markup that survives KSES.

Invalid after a hand edit in the Code editor

The HTML no longer matches what save() builds from the delimiter’s attributes (for example, an ISBN changed in the HTML but not in the JSON).

The front end shows the old markup after a save() change, although the editor migrated the block

Static HTML changes only when each post is saved again. Re-save the affected posts, or render on the server.

A dynamic block’s settings from the sidebar have no effect on the front end

The wrapper in render.php is printed without get_block_wrapper_attributes().

A render.php block renders nothing although its query finds posts

The file returns the HTML with return; the wrapper only captures printed output. Print it.

A render_callback block’s output appears above the rest of the content

The callback echoes instead of returning a string.

The rest of the template shows the wrong post after the block

The render code called the_post() without wp_reset_postdata().

Undefined index notices for $attributes['genre']

The attribute has no default in block.json; declare one or use $attributes['genre'] ?? ''.

A sourced attribute (title) is empty in render.php

Sourced attributes exist only in the browser. Store the value without source, or read it from $content.

The editor preview of a ServerSideRender block shows "Error loading block"

The REST request to /wp/v2/block-renderer/ failed: a PHP error in render.php, or an attribute not declared in block.json.

Security

Attributes come from post_content, which any user who can edit the post can write by hand in the Code editor or send through the REST API, so a render callback treats them as untrusted input: cast integers and clamp them (count is limited to 1–12 above, so a crafted value cannot request thousands of posts), pass slugs through sanitize_key(), and escape every value at output with esc_html(), esc_url() or esc_attr(). A query in a render callback must not leak content the visitor may not see: both Lumen queries ask for post_status publish explicitly, and any other status needs a capability check for the current user. Only get_block_wrapper_attributes() (already escaped) and $content (rendered block HTML) are printed unescaped. On the static side, save() output goes through React’s escaping unless RawHTML is used, and KSES filters the stored HTML for users without unfiltered_html. More in Security for Developers.

What older books and tutorials get wrong

The three older books (WordPress All-in-One For Dummies, Pro WordPress, Ultimate WordPress Handbook) do not cover block development: Pro WordPress builds dynamic content as shortcodes and widgets, and the other two use blocks only as authors. Tutorials from the WordPress 5.x years are where outdated block code usually comes from:

Older advice Current practice

Put className={ props.className } on the wrapper

useBlockProps() in edit and useBlockProps.save() in save (apiVersion 2 and later; use 3).

wp.editor.RichText, wp.blocks.source attribute helpers

RichText from @wordpress/block-editor; attribute sources declared in block.json, with the rich-text type and source.

A dynamic block is registered with render_callback in PHP and the attributes repeated in JavaScript

One block.json with "render": "file:./render.php" (6.1); both sides read the same attributes.

Wrap the PHP output in a hand-written <div class="wp-block-…">

get_block_wrapper_attributes() (5.6), which also applies block supports.

Shortcodes for lists of posts inside content

A dynamic block (or the core Query Loop block), which has a visual editor and validation-free markup.

Fix invalid blocks by asking authors to click "Attempt recovery"

Ship a deprecated entry with every save() change.

The full list across the section is in Where Older Books and Tutorials Are Outdated.