TechEarl

Build a Dynamic Gutenberg Block (Server-Rendered with render_callback)

How to build a dynamic Gutenberg block: render the front-end markup in PHP at request time with render_callback (or the block.json render property), return null from save, and preview it in the editor with ServerSideRender.

Ishan Karunaratne⏱️ 9 min readUpdated
Share thisCopied
How to build a dynamic Gutenberg block that renders its front-end HTML in PHP at request time using render_callback or the block.json render property, with a save function that returns null and a ServerSideRender editor preview.

A dynamic Gutenberg block renders its front-end markup in PHP at request time instead of saving fixed HTML into the post. You point your block at a PHP callback, the callback runs on every page view, and whatever it returns is what the reader sees. The two ways to wire it:

json
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "te/latest-posts",
  "title": "TE Latest Posts",
  "category": "widgets",
  "attributes": {
    "count": { "type": "number", "default": 5 }
  },
  "editorScript": "file:./index.js",
  "render": "file:./render.php"
}
php
<?php
// render.php runs on every front-end request for this block.
$count = isset( $attributes['count'] ) ? absint( $attributes['count'] ) : 5;

$query = new WP_Query( array(
    'posts_per_page'      => $count,
    'post_status'         => 'publish',
    'no_found_rows'       => true,
    'ignore_sticky_posts' => true,
) );

if ( ! $query->have_posts() ) {
    return;
}

echo '<ul ' . get_block_wrapper_attributes() . '>';
while ( $query->have_posts() ) {
    $query->the_post();
    printf(
        '<li><a href="%1$s">%2$s</a></li>',
        esc_url( get_permalink() ),
        esc_html( get_the_title() )
    );
}
echo '</ul>';

wp_reset_postdata();

The "render": "file:./render.php" line is the modern way to wire this, and it landed in WordPress 6.1 (November 2022). Inside render.php, three variables are already in scope: $attributes, $content, and $block. The save side of the block returns null so nothing is frozen into post content. That is the whole shape of a dynamic block.

Why dynamic instead of static

A static block saves its rendered HTML into post_content the moment you hit Update. That is perfect for a paragraph or a pull-quote: the content does not change, so baking it in is fast and cache-friendly. I cover that path in the companion piece on registering a static block with block.json; this article is the other half.

The problem is everything that is supposed to change after you save. A "latest 5 posts" block saved as static HTML lists whatever the five latest posts were on the day you placed it, forever. A "current year" in a footer block freezes to 2021. A live order count, a price pulled from another table, anything per-request or per-visitor: static markup goes stale the instant the underlying data moves, and the editor has no idea, because the saved HTML still validates.

Dynamic rendering solves this by not saving the markup at all. The block stores only its attributes (the count, the chosen category, a heading), and the PHP callback rebuilds the HTML from current data on every request. Use a dynamic block when:

  • The output depends on data that changes after save (recent posts, comment counts, stock levels).
  • The output depends on the request itself (the logged-in user, the current date, a query string).
  • You want to change the markup later by editing PHP, not by re-saving every post that uses the block.

The trade-off is that the work happens on each page load rather than once at save time, so a heavy query inside a dynamic block wants a transient or object cache in front of it. A static block has already paid that cost; a dynamic one pays it per view.

The callback signature, attributes, and escaping

render_callback is the original mechanism and predates the render property. It is an argument to register_block_type() (which has existed since WordPress 5.0), and it is what the block.json render file compiles down to. If you are not using a render.php file, you register the callback directly:

php
add_action( 'init', function () {
    register_block_type( __DIR__ . '/build', array(
        'render_callback' => 'te_render_latest_posts',
    ) );
} );

/**
 * @param array    $attributes The block's saved attribute values.
 * @param string   $content    Inner block markup (empty for most dynamic blocks).
 * @param WP_Block $block      The block instance (context, name, parsed attrs).
 */
function te_render_latest_posts( $attributes, $content, $block ) {
    $count = isset( $attributes['count'] ) ? absint( $attributes['count'] ) : 5;

    $query = new WP_Query( array(
        'posts_per_page'      => $count,
        'post_status'         => 'publish',
        'no_found_rows'       => true,
        'ignore_sticky_posts' => true,
    ) );

    if ( ! $query->have_posts() ) {
        return '';
    }

    $items = '';
    while ( $query->have_posts() ) {
        $query->the_post();
        $items .= sprintf(
            '<li><a href="%1$s">%2$s</a></li>',
            esc_url( get_permalink() ),
            esc_html( get_the_title() )
        );
    }
    wp_reset_postdata();

    return sprintf(
        '<ul %1$s>%2$s</ul>',
        get_block_wrapper_attributes(),
        $items
    );
}

Two things to get right. First, the callback returns or echoes a string of HTML. When you pass render_callback to register_block_type(), return the markup as a string (echoing also works, but returning is cleaner). When you use a render.php file via the render property, the file itself is the output buffer, so you echo directly the way the first example does. Pick the form that matches how you wired it.

Second, the three arguments. $attributes is the associative array of the block's saved attributes (the count above, plus anything else declared in block.json). $content is the inner-block markup, which is empty for a leaf block like this and useful for container blocks that wrap other blocks. $block is the WP_Block instance, carrying the block's context and parsed attributes; the third parameter was added in WordPress 5.5, so older callbacks only declared the first two. You can declare just $attributes if that is all you need.

On escaping: a dynamic block runs trusted attribute values through untrusted-looking output, so escape at the point of output exactly as you would anywhere else in WordPress. Use esc_url() for hrefs, esc_html() for text, esc_attr() for attribute values, and absint() to clamp a numeric attribute before it touches a query. Never interpolate a raw attribute straight into HTML. The get_block_wrapper_attributes() helper is the right way to emit the wrapper's class and style, because it folds in the editor-chosen alignment and color classes for you and returns an already-escaped attribute string.

Editor preview with ServerSideRender

A dynamic block has no saved markup, so the editor cannot show the front-end output by replaying HTML the way it does for a static block. The save function returns null:

javascript
import { registerBlockType } from '@wordpress/blocks';
import ServerSideRender from '@wordpress/server-side-render';
import { useBlockProps } from '@wordpress/block-editor';
import metadata from './block.json';

registerBlockType( metadata.name, {
    edit( { attributes } ) {
        const blockProps = useBlockProps();
        return (
            <div { ...blockProps }>
                <ServerSideRender
                    block={ metadata.name }
                    attributes={ attributes }
                />
            </div>
        );
    },
    save() {
        return null;
    },
} );

ServerSideRender (the @wordpress/server-side-render package) is the editor-side answer. It posts the block's name and current attributes to the REST API, the server runs your render_callback, and the editor shows the real PHP output as a live preview. Change the count in the inspector and the preview re-fetches. It is a little heavier than a pure-JS edit component because every attribute change hits the server, so for fast-iterating controls some developers render an approximation in JS for editing and reserve ServerSideRender for the genuinely server-only parts. For most dynamic blocks the straight ServerSideRender preview is exactly what you want, and save returning null is what tells WordPress this block has no static content to validate.

Verify it worked

After building and activating the block:

  • Editor: insert the block. You should see the live server preview, not a blank box or a "this block contains unexpected or invalid content" validation error. A validation error almost always means save is returning markup instead of null.
  • Front end: view the published page. The markup should reflect current data. Add a new post, reload, and the latest-posts list should change without re-saving the page that holds the block.
  • Database: the saved block in post_content should be just the comment delimiters and attributes (<!-- wp:te/latest-posts {"count":5} /-->) with no inner HTML. If you see saved HTML, the block is not actually dynamic.
  • REST: if the editor preview is blank, check the browser Network tab for the /wp/v2/block-renderer/te/latest-posts request. A 401 or 403 there usually means a current-user permission problem; a 500 means your PHP callback threw.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressGutenbergPHPBlock Editorrender_callback

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Register a Custom Gutenberg Block with block.json

The modern way to register a custom Gutenberg block: a block.json metadata file, register_block_type( __DIR__ . '/build/callout' ) on the init hook, and a @wordpress/scripts build step. One source of truth for PHP and JS.