Static versus dynamic
In the previous lesson the block's save function produced HTML stored in the post. That is a static block, and it is perfect for fixed content. It is wrong for content that changes: a list of the latest recipes, today's date, a form that needs a nonce, content that depends on the visitor. The stored HTML would go stale, and every change to the markup would break existing posts.
A dynamic block stores only its attributes. Its HTML is produced by PHP on each page view. The JavaScript save returns null.
block.json for a dynamic block
This block shows the newest recipes. It uses a custom post type like the one from Custom post types, registered here to keep the lesson self-contained:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "bakery/latest-recipes",
"title": "Latest recipes",
"category": "widgets",
"icon": "list-view",
"description": "Shows the newest recipes.",
"textdomain": "bakery-latest-recipes",
"attributes": {
"count": { "type": "number", "default": 3 },
"showDate": { "type": "boolean", "default": false }
},
"supports": {
"html": false,
"align": [ "wide", "full" ],
"spacing": { "margin": true, "padding": true }
},
"editorScript": "file:./index.js",
"render": "file:./render.php"
}"render": "file:./render.php" tells WordPress to run that PHP file whenever the block is displayed.
The PHP render file
Three variables are available inside it: $attributes (the block's attributes, with defaults filled in), $content (inner blocks' HTML, empty here) and $block (the WP_Block object).
<?php
/**
* Renders the Latest recipes block.
*
* @var array $attributes Block attributes.
* @var string $content Inner blocks.
* @var WP_Block $block The block instance.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$count = max( 1, min( 12, (int) $attributes['count'] ) ); // never trust attribute values
$recipes = get_posts( [
'post_type' => 'recipe',
'posts_per_page' => $count,
'orderby' => 'date',
'order' => 'DESC',
'no_found_rows' => true,
] );
if ( ! $recipes ) {
return; // print nothing when there is nothing to show
}
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
<ul class="bakery-latest-recipes">
<?php foreach ( $recipes as $recipe ) : ?>
<li>
<a href="<?php echo esc_url( get_permalink( $recipe ) ); ?>"><?php echo esc_html( get_the_title( $recipe ) ); ?></a>
<?php if ( ! empty( $attributes['showDate'] ) ) : ?>
<time datetime="<?php echo esc_attr( get_the_date( 'c', $recipe ) ); ?>"><?php echo esc_html( get_the_date( 'j F Y', $recipe ) ); ?></time>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>Notes:
get_block_wrapper_attributes()prints the wrapper'sclassandstyleattributes: the generated classwp-block-bakery-latest-recipes, plus anything fromsupports(spacing, alignment) and the editor's "Additional CSS class". It is the PHP twin ofuseBlockProps.save().- Everything printed is escaped:
esc_url(),esc_html(),esc_attr(). Attributes come from post content that editors write, so treat them as input. return;in a render file means "no output".
Registering and the editor script
<?php
/**
* Plugin Name: Bakery Latest Recipes
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'init', function () {
register_post_type( 'recipe', [
'label' => 'Recipes',
'public' => true,
'supports' => [ 'title', 'editor' ],
'show_in_rest' => true,
] );
register_block_type( __DIR__ );
} );The JavaScript has less to do. edit shows a preview made by the server through the ServerSideRender component, plus controls for the attributes, and save returns null:
<?php
return [
'dependencies' => [ 'wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n', 'wp-server-side-render' ],
'version' => '1.0.0',
];( function ( blocks, blockEditor, components, element, ServerSideRender ) {
const el = element.createElement;
const { useBlockProps, InspectorControls } = blockEditor;
const { PanelBody, RangeControl, ToggleControl } = components;
blocks.registerBlockType( 'bakery/latest-recipes', {
edit: function ( props ) {
const { attributes, setAttributes } = props;
return el(
'div',
useBlockProps(),
el(
InspectorControls,
null,
el(
PanelBody,
{ title: 'Settings' },
el( RangeControl, {
label: 'Number of recipes',
value: attributes.count,
min: 1,
max: 12,
onChange: function ( count ) {
setAttributes( { count: count } );
},
} ),
el( ToggleControl, {
label: 'Show dates',
checked: attributes.showDate,
onChange: function ( showDate ) {
setAttributes( { showDate: showDate } );
},
} )
)
),
el( ServerSideRender, { block: 'bakery/latest-recipes', attributes: attributes } )
);
},
// Dynamic: nothing is stored, PHP renders on the front end.
save: function () {
return null;
},
} );
} )( window.wp.blocks, window.wp.blockEditor, window.wp.components, window.wp.element, window.wp.serverSideRender );wp plugin activate bakery-latest-recipes
wp post create --post_type=recipe --post_title="Rye bread" --post_status=publish --post_date="2026-02-01 09:00:00" --porcelain
wp post create --post_type=recipe --post_title="Sourdough loaf" --post_status=publish --post_date="2026-02-05 09:00:00" --porcelain
wp post create --post_type=recipe --post_title="Focaccia" --post_status=publish --post_date="2026-02-10 09:00:00" --porcelain
wp post create --post_type=recipe --post_title="Ciabatta" --post_status=publish --post_date="2026-02-15 09:00:00" --porcelainPlugin 'bakery-latest-recipes' activated.
Success: Activated 1 of 1 plugins.
4
5
6
7What is stored, and what visitors see
An editor adds the block, sets the number to 2 and turns on dates. The post stores only the comment, with no HTML:
<?php
$content = '<!-- wp:bakery/latest-recipes {"count":2,"showDate":true} /-->';
echo $content, "\n\n";
// What visitors get: WordPress renders the block with PHP.
echo do_blocks( $content ), "\n";<!-- wp:bakery/latest-recipes {"count":2,"showDate":true} /-->
<div class="wp-block-bakery-latest-recipes">
<ul class="bakery-latest-recipes">
<li>
<a href="http://localhost:8080/recipe/ciabatta/">Ciabatta</a>
<time datetime="2026-02-15T09:00:00+00:00">15 February 2026</time>
</li>
<li>
<a href="http://localhost:8080/recipe/focaccia/">Focaccia</a>
<time datetime="2026-02-10T09:00:00+00:00">10 February 2026</time>
</li>
</ul>
</div>The self-closing comment <!-- ... /--> is the whole saved block. The block can be changed in the plugin at any time (new markup, new classes) and every existing post updates, because nothing was stored to compare against.
On a real page, it appears wherever the editor placed it:
<?php
wp_insert_post( [
'post_title' => 'Fresh from the oven',
'post_name' => 'fresh',
'post_status' => 'publish',
'post_type' => 'page',
'post_content' => '<!-- wp:bakery/latest-recipes {"count":3} /-->',
] );GET /fresh//recipe/ciabatta/">Ciabatta
/recipe/focaccia/">Focaccia
/recipe/sourdough-loaf/">Sourdough loafrender_callback instead of a file
For small blocks you can register the PHP as a function instead of a file. The callback receives the same three values and returns the HTML as a string:
register_block_type( __DIR__, [
'render_callback' => function ( array $attributes, string $content, WP_Block $block ): string {
return '<p ' . get_block_wrapper_attributes() . '>' . esc_html( gmdate( 'Y' ) ) . '</p>';
},
] );Inner blocks
If the block contains other blocks (like a Group does), keep their HTML with <InnerBlocks /> in edit, <InnerBlocks.Content /> in save, and print $content in the render file. That makes a dynamic wrapper around static content.
Dynamic or static?
| Static | Dynamic | |
|---|---|---|
| Output | stored in the post | generated by PHP on each view |
| Speed | fastest | a little PHP work per view (cache with transients if heavy) |
| Changing the markup later | needs deprecations | automatic |
| Best for | fixed content: callouts, buttons, layouts | changing data: latest posts, forms, user-specific content |
Common mistakes
- Unescaped attributes in the render file.
- Forgetting
get_block_wrapper_attributes(), sosupportsdo nothing. - Heavy queries on every view without caching (see Caching with transients).
- Returning
nullfromsavein a block that has no PHP renderer: the block shows nothing. - Forgetting
"render"inblock.json.
Exercise
Extend the render file to show the recipes' excerpts, but only on single pages (hint: is_singular()), cut to 15 words.
Show one possible answer
<?php if ( is_singular() ) : ?>
<p><?php echo esc_html( wp_trim_words( get_the_excerpt( $recipe ), 15 ) ); ?></p>
<?php endif; ?>Add it inside the <li>, after the link. get_the_excerpt( $recipe ) takes the post, and esc_html() escapes the result.
Key points
- A dynamic block returns null from its JavaScript save function and renders its HTML in PHP, on each page view.
- Use a render file in block.json ("render": "file:./render.php") or a render_callback; WordPress passes $attributes, $content and $block.
- Choose dynamic for content that changes (latest posts, user-specific, data from a database); static blocks are for fixed content.
- Escape everything in the PHP render file, use get_block_wrapper_attributes() for the wrapper, and use ServerSideRender or a placeholder for the editor preview.