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:
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 |
|---|---|---|
|
In the editor, every time the block re-renders (a React component). |
Props: |
|
In the editor, when the post is serialized, and again when the post is loaded, to validate the stored HTML. |
Props: |
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
Source: Code Reference: parse_blocks()
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"inblock.json(WordPress 6.1+).register_block_type()wraps the file in a closure that runsob_start(), loads the file withrequireand returns the buffer, so the file prints HTML and its variables are local to the closure. -
render_callbackin the arguments ofregister_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 |
|---|---|
|
The block’s attributes from the delimiter’s JSON, validated against the |
|
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. |
|
The |
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>
Source: Code Reference: WP_Query
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>
Source: Code Reference: do_blocks()
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>
Source: Code Reference: WP_Block
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.
Source: Code Reference: render_block
Choosing static, dynamic or hybrid
| Question | Static | Dynamic | Hybrid |
|---|---|---|---|
Where is the HTML produced? |
In the browser, by |
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 |
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 |
Yes |
Only the delimiter |
The inner blocks |
Lumen example |
|
|
|
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 |
|
A block becomes invalid only when certain users edit it |
KSES removed attributes or tags from the saved HTML for users without |
Invalid after a hand edit in the Code editor |
The HTML no longer matches what |
The front end shows the old markup after a |
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 |
A |
The file returns the HTML with |
A |
The callback echoes instead of returning a string. |
The rest of the template shows the wrong post after the block |
The render code called |
Undefined index notices for |
The attribute has no |
A sourced attribute ( |
Sourced attributes exist only in the browser. Store the value without |
The editor preview of a |
The REST request to |
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 |
|
|
|
A dynamic block is registered with |
One |
Wrap the PHP output in a hand-written |
|
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 |
The full list across the section is in Where Older Books and Tutorials Are Outdated.