Dynamic Blocks
Dynamic blocks store attributes only and build their markup in PHP on every request, through render.php or a render_callback.
function my_render( array $attributes, string $content, WP_Block $block ): stringA static block writes finished HTML into post_content. A dynamic block writes only its attributes and produces markup in PHP each time the post is rendered. Anything that must be correct at read time — recent posts, a stock level, the current user's name, a count — has to be dynamic, because a static block freezes the value at the moment the author pressed Update.
Two ways to attach the PHP
// Shorthand, in block.json (WordPress 6.1):
"render": "file:./render.php"
// Longhand, in PHP:
register_block_type( __DIR__ . '/build/latest', array(
'render_callback' => 'sitekit_render_latest',
) );
They reach the same place. render.php is included with $attributes (array), $content (the saved inner markup, usually empty) and $block (a WP_Block) already in scope, and whatever it prints is the output. A render_callback receives those same three values as arguments and must return the string rather than echo it.
The wrapper element
Call get_block_wrapper_attributes() (WordPress 5.6) on the outermost element. It produces the block's own class plus every class and inline style the supports settings generated — colour, spacing, typography, the custom class name. Note that the anchor support has no server-side handler in WordPress 6.8, so a dynamic block's id is not emitted for you; output it from $attributes['anchor'] yourself. Build the class attribute by hand instead and the user's editor choices produce nothing on the front end.
The trade
The block's save returns null, so only the self-closing delimiter is stored. Nothing saved means no validation errors ever, and a PHP change updates every existing post at once. It also means the work runs on every request.
Example
<?php
/**
* render.php for sitekit/latest-posts.
*
* Included by WordPress with these already in scope — do not declare them.
*
* @var array $attributes Validated against the schema in block.json.
* @var string $content Saved inner markup (empty for this block).
* @var WP_Block $block The block instance, including its context.
*/
$posts = get_posts(
array(
'numberposts' => max( 1, min( 20, (int) ( $attributes['count'] ?? 3 ) ) ),
'post_status' => 'publish',
'ignore_sticky_posts' => true,
)
);
if ( ! $posts ) {
return; // Returning early from an included file simply prints nothing.
}
<ul echo get_block_wrapper_attributes(); // Already escaped by core. >
foreach ( $posts as $post ) :
<li>
<a href=" echo esc_url( (string) get_permalink( $post ) ); ">
echo esc_html( get_the_title( $post ) );
</a>
if ( ! empty( $attributes['showDate'] ) ) :
<time datetime=" echo esc_attr( get_the_date( 'c', $post ) ); ">
echo esc_html( get_the_date( '', $post ) );
</time>
endif;
</li>
endforeach;
</ul>When to use it
- Showing the latest posts, products or events inside page content
- Rendering something that depends on the current user or request
- Changing the markup of a shipped block across a whole site without touching any post
More examples
The JavaScript half of a dynamic block
ServerSideRender (from @wordpress/server-side-render, listed as a dependency in package.json) round-trips to the server on every attribute change, so it is fine for a settings-driven block and poor for anything the user types into directly. For a block with rich inline editing, write a JS preview in edit and keep the PHP for the front end.
import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import ServerSideRender from '@wordpress/server-side-render';
import metadata from './block.json';
registerBlockType( metadata, {
edit( { attributes } ) {
return (
<div { ...useBlockProps() }>
{ /* Asks the REST API to run the PHP so the editor shows real output. */ }
<ServerSideRender block={ metadata.name } attributes={ attributes } />
</div>
);
},
// No save function at all is the same as save: () => null — either way only
// the self-closing delimiter and the attributes JSON are stored.
} );render_callback with inner blocks
A dynamic wrapper around static children: the inner blocks save their own markup normally and arrive as $content, while the PHP decides only the outer element. This is the pattern to use when the container is conditional (a link, a role, a permission check) but the contents are ordinary author-written blocks.
add_action( 'init', 'sitekit_register_card' );
function sitekit_register_card(): void {
register_block_type(
__DIR__ . '/build/card',
array( 'render_callback' => 'sitekit_render_card' )
);
}
/**
* $content holds the inner blocks, already rendered and escaped by core.
* Wrap it; never escape it again, and never build it by hand.
*/
function sitekit_render_card( array $attributes, string $content, WP_Block $block ): string {
$href = (string) ( $attributes['href'] ?? '' );
if ( '' === $href ) {
return sprintf(
'<div %s>%s</div>',
get_block_wrapper_attributes(),
$content
);
}
return sprintf(
'<a %s href="%s">%s</a>',
get_block_wrapper_attributes( array( 'class' => 'is-linked' ) ),
esc_url( $href ),
$content
);
}Caching an expensive dynamic block
Dynamic means the PHP runs on every uncached page view, including for logged-out visitors on sites with no page cache. If the block calls an HTTP API or runs a heavy query, wrap it in a transient. Leave get_block_wrapper_attributes() inside the cached string only if the supports settings are part of the key — otherwise call it outside the cache.
function sitekit_render_feed( array $attributes, string $content, WP_Block $block ): string {
// The cache key must include every attribute that changes the output,
// or two blocks with different settings will share one cached result.
$key = 'sitekit_feed_' . md5( wp_json_encode( $attributes ) );
$html = get_transient( $key );
if ( false !== $html ) {
return (string) $html;
}
$html = sitekit_build_feed_html( $attributes );
set_transient( $key, $html, 15 * MINUTE_IN_SECONDS );
return $html;
}
Discussion