Gutenberg, introduced in WordPress 5.0 (December 2018), brought a brand-new block editor to the CMS. While static blocks are straightforward to build, many real-world scenarios require output that changes dynamically — for example, a list of recent posts or user-specific data. That’s where server-side rendering comes in.
Problem: A Gutenberg block needs to display data that changes over time — such as recent posts, a live widget, or database content — but static HTML saved in the save function becomes stale immediately.
Solution: Register a dynamic block with a render_callback in PHP instead of a save function. Every page load executes fresh PHP, so the block always shows current data — you only need an edit function in JavaScript and a PHP callback for the front end.
A dynamic block registers a render_callback in PHP instead of saving static HTML in the database. Every time the block is displayed on the front end, WordPress calls your PHP function and returns fresh markup.
Step 1. Register the block in PHP (functions.php or a plugin file):
<?php
function my_dynamic_block_register() {
// Make sure the block editor assets are available.
if ( ! function_exists( 'register_block_type' ) ) {
return;
}
wp_register_script(
'my-dynamic-block-editor',
get_template_directory_uri() . '/blocks/dynamic-block/editor.js',
[ 'wp-blocks', 'wp-element', 'wp-editor', 'wp-components' ],
filemtime( get_template_directory() . '/blocks/dynamic-block/editor.js' )
);
register_block_type( 'my-theme/dynamic-block', [
'editor_script' => 'my-dynamic-block-editor',
'render_callback' => 'my_dynamic_block_render',
'attributes' => [
'numberOfPosts' => [
'type' => 'number',
'default' => 3,
],
],
] );
}
add_action( 'init', 'my_dynamic_block_register' );
Step 2. Write the render callback. The function receives the block attributes and the inner blocks content as arguments:
<?php
function my_dynamic_block_render( $attributes ) {
$number = isset( $attributes['numberOfPosts'] ) ? (int) $attributes['numberOfPosts'] : 3;
$query = new WP_Query( [
'post_type' => 'post',
'posts_per_page' => $number,
'no_found_rows' => true,
] );
if ( ! $query->have_posts() ) {
return '<p>' . esc_html__( 'No posts found.', 'my-theme' ) . '</p>';
}
$output = '<ul class="my-dynamic-block">';
while ( $query->have_posts() ) {
$query->the_post();
$output .= sprintf(
'<li><a href="%s">%s</a></li>',
esc_url( get_permalink() ),
esc_html( get_the_title() )
);
}
wp_reset_postdata();
$output .= '</ul>';
return $output;
}
Step 3. Create the editor script (editor.js). The key detail for dynamic blocks is that the save function must return null — WordPress will not save any HTML for this block and will always call the PHP callback instead:
const { registerBlockType } = wp.blocks;
const { InspectorControls } = wp.editor;
const { PanelBody, RangeControl } = wp.components;
const { Fragment } = wp.element;
const { withSelect } = wp.data;
registerBlockType( 'my-theme/dynamic-block', {
title: 'Recent Posts (Dynamic)',
icon: 'list-view',
category: 'widgets',
attributes: {
numberOfPosts: {
type: 'number',
default: 3,
},
},
edit: withSelect( ( select, props ) => {
const { numberOfPosts } = props.attributes;
return {
posts: select( 'core' ).getEntityRecords( 'postType', 'post', {
per_page: numberOfPosts,
_fields: 'id,title,link',
} ),
};
} )( ( { posts, attributes, setAttributes } ) => {
const { numberOfPosts } = attributes;
return (
wp.element.createElement( Fragment, null,
wp.element.createElement( InspectorControls, null,
wp.element.createElement( PanelBody, { title: 'Settings' },
wp.element.createElement( RangeControl, {
label: 'Number of posts',
value: numberOfPosts,
onChange: ( val ) => setAttributes( { numberOfPosts: val } ),
min: 1,
max: 10,
} )
)
),
posts
? wp.element.createElement( 'ul', { className: 'my-dynamic-block' },
posts.map( ( post ) =>
wp.element.createElement( 'li', { key: post.id },
wp.element.createElement( 'a', { href: post.link },
post.title.rendered
)
)
)
)
: wp.element.createElement( 'p', null, 'Loading…' )
)
);
} ),
// Dynamic blocks must return null from save().
save: () => null,
} );
Because the block's output is generated entirely in PHP, it always reflects the latest content without the editor needing to update the saved HTML. This makes dynamic blocks ideal for listings, feeds, or anything that should stay in sync with the database automatically.
NOTE: When you return null from save(), WordPress stores a special comment delimiter in the post content instead of markup. On the front end it silently invokes render_callback — so if you ever remove the block registration, those blocks will show a "This block has encountered an error" notice in the editor.