Dynamic blocks: what every WordPress developer should know
We've all built a block that pulls in fresh data—latest posts, user information, real-time metrics. The challenge is that static blocks won't cut it; they render once and freeze. Dynamic blocks: what every WordPress developer should know covers server-side rendering, when to reach for it, and how to avoid the pitfalls that catch most teams when they first implement them.
When to use dynamic blocks
The core question is simple: does your block's output change outside the editor's control? If yes, you need a dynamic block.
Consider a few scenarios we've encountered in production:
- Latest posts block — You want to display the three most recent posts. That list changes when new content is published. A static block saved to the database would become stale within hours.
- User profile widget — Displays the current logged-in user's name, avatar, and role. This varies per visitor, sometimes per request.
- Real-time stats dashboard — Shows analytics, inventory counts, or queue depths pulled from an API or database query. It needs fresh data on every page load.
- Geolocation-aware content — Blocks that change based on the visitor's location, device, or time of day.
If your block content stays the same for all visitors and doesn't change unless an editor explicitly updates it, a static block is faster and more efficient. Save dynamic blocks for situations where the data genuinely needs refreshing.
Using render_callback and render.php
WordPress gives you two paths to handle server-side rendering: a render_callback function or a render.php file declared in block.json.
The render_callback approach
This is the traditional method. You register your block and point it to a function that outputs the HTML:
register_block_type( 'myteam/latest-posts', array(
'editor_script' => 'myteam-latest-posts-editor',
'editor_style' => 'myteam-latest-posts-editor-style',
'style' => 'myteam-latest-posts-style',
'render_callback' => 'myteam_render_latest_posts',
'attributes' => array(
'postsToShow' => array(
'type' => 'number',
'default' => 3,
),
'postType' => array(
'type' => 'string',
'default' => 'post',
),
),
) );
function myteam_render_latest_posts( $attributes ) {
$args = array(
'posts_per_page' => $attributes['postsToShow'],
'post_type' => $attributes['postType'],
'orderby' => 'date',
'order' => 'DESC',
);
$posts = get_posts( $args );
if ( empty( $posts ) ) {
return '<p>No posts found.</p>';
}
$html = '<div class="wp-block-myteam-latest-posts">';
foreach ( $posts as $post ) {
$html .= sprintf(
'<article><h3><a href="%s">%s</a></h3><p>%s</p></article>',
esc_url( get_permalink( $post ) ),
esc_html( $post->post_title ),
wp_trim_words( get_the_excerpt( $post ), 15 )
);
}
$html .= '</div>';
return $html;
}The render.php file approach
With modern block development, you can declare a render.php file in your block.json:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "myteam/latest-posts",
"title": "Latest Posts",
"category": "widgets",
"attributes": {
"postsToShow": {
"type": "number",
"default": 3
},
"postType": {
"type": "string",
"default": "post"
}
},
"render": "file:./render.php"
}Then create render.php in the same directory:
<?php
/**
* Render callback for Latest Posts block
*
* @var array $attributes Block attributes
* @var string $content Block content (empty for dynamic blocks)
* @var WP_Block $block Block object
*/
$args = array(
'posts_per_page' => $attributes['postsToShow'] ?? 3,
'post_type' => $attributes['postType'] ?? 'post',
'orderby' => 'date',
'order' => 'DESC',
);
$posts = get_posts( $args );
if ( empty( $posts ) ) {
echo '<p>No posts found.</p>';
return;
}
?>
<div class="wp-block-myteam-latest-posts">
<?php foreach ( $posts as $post ) : ?>
<article>
<h3>
<a href="<?php echo esc_url( get_permalink( $post ) ); ?>">
<?php echo esc_html( $post->post_title ); ?>
</a>
</h3>
<p><?php echo wp_trim_words( get_the_excerpt( $post ), 15 ); ?></p>
</article>
<?php endforeach; ?>
</div>The render.php approach is cleaner for larger blocks because you're writing HTML templates rather than concatenating strings. Both work identically on the front end.
Save returns null, not JSX
This trips up developers coming from React. In a dynamic block, the save function should return null, not JSX or HTML:
const { registerBlockType } = wp.blocks;
registerBlockType( 'myteam/latest-posts', {
title: 'Latest Posts',
category: 'widgets',
attributes: {
postsToShow: {
type: 'number',
default: 3,
},
postType: {
type: 'string',
default: 'post',
},
},
edit: ( { attributes, setAttributes } ) => {
return (
<div>
<label>
Posts to show:
<input
type="number"
value={ attributes.postsToShow }
onChange={ ( e ) => setAttributes( { postsToShow: parseInt( e.target.value ) } ) }
/>
</label>
</div>
);
},
save: () => {
return null;
},
} );When you return null, WordPress knows to skip the static save step. The entire output will be generated server-side on every page load. If you accidentally return HTML, WordPress will try to use that as the fallback, and your server-side render will be ignored—a silent failure that wastes hours debugging.
Server-side rendering in the editor with ServerSideRender
Here's a problem: in the block editor, you want to show editors what the block will look like on the front end. But the editor runs client-side JavaScript, not PHP. How do you preview dynamic content?
WordPress provides the ServerSideRender component. It fetches the rendered output from your server via REST API and displays it in the editor:
const { ServerSideRender } = wp.serverSideRender;
const { registerBlockType } = wp.blocks;
const { InspectorControls } = wp.blockEditor;
const { PanelBody, RangeControl, SelectControl } = wp.components;
registerBlockType( 'myteam/latest-posts', {
title: 'Latest Posts',
category: 'widgets',
attributes: {
postsToShow: {
type: 'number',
default: 3,
},
postType: {
type: 'string',
default: 'post',
},
},
edit: ( { attributes, setAttributes } ) => {
return (
<>
<InspectorControls>
<PanelBody title="Settings">
<RangeControl
label="Posts to show"
value={ attributes.postsToShow }
onChange={ ( value ) => setAttributes( { postsToShow: value } ) }
min={ 1 }
max={ 10 }
/>
<SelectControl
label="Post type"
value={ attributes.postType }
options={ [
{ label: 'Posts', value: 'post' },
{ label: 'Pages', value: 'page' },
] }
onChange={ ( value ) => setAttributes( { postType: value } ) }
/>
</PanelBody>
</InspectorControls>
<ServerSideRender
block="myteam/latest-posts"
attributes={ attributes }
/>
</>
);
},
save: () => {
return null;
},
} );When an editor adjusts the "Posts to show" slider, ServerSideRender automatically makes a REST request with the new attributes, fetches the updated HTML from your server, and re-renders it in the editor. The preview stays in sync without the editor needing to understand PHP or database queries.
One caveat: ServerSideRender adds latency. The editor has to wait for a network round-trip. For blocks with expensive queries, this can feel sluggish. Consider caching or optimizing your render function if editors complain about slow preview updates.
Common mistakes
- Returning HTML instead of null from save() — This causes WordPress to ignore your server-side render and use the static fallback. Always return
null. - Not escaping output in render.php — Use
esc_html(),esc_url(), andwp_kses_post()to prevent XSS vulnerabilities. Dynamic blocks pull data from the database; assume it needs escaping. - Running expensive queries without caching — If your render function runs a complex query on every page load, you'll tank performance. Use transients or object caching to store results for a few minutes.
- Forgetting to handle empty state — What happens if no posts match your criteria? Return a helpful message to the editor and a graceful fallback to visitors.
- Hardcoding user capability checks — If your block shows admin-only data, verify capabilities in the render function, not just the editor. Front-end visitors might see something unintended.
Key takeaways
- Use dynamic blocks when content changes outside the editor—latest posts, user data, real-time metrics, or anything per-visitor or per-request.
- Implement server-side rendering via
render_callbackorrender.phpinblock.json; the latter is cleaner for complex templates. - Always return
nullfrom thesave()function in dynamic blocks to signal WordPress to skip the static save step. - Use
ServerSideRenderin the editor to preview dynamic content, but be aware it adds network latency on every attribute change. - Escape all output, handle empty states, and cache expensive queries to build dynamic blocks that are secure, performant, and user-friendly.






Comments
Loading comments…