How to build a dynamic WordPress block with block.json and render.php
A step-by-step tutorial for WordPress 7.1: scaffold a dynamic block with @wordpress/create-block, read the post ID from block context, add native supports, and render escaped HTML from render.php.
Updated 10 min read

In this article
Many sites need small pieces of content that must always be correct when a page loads: a "last updated" line on documentation, a count of open jobs, a price pulled from another system. If you build these as static blocks, the HTML is frozen into the post when it is saved, and it goes stale the moment the underlying data changes.
WordPress solves this with dynamic blocks: the editor stores only the block comment and its attributes, and PHP renders the HTML on every request. Since WordPress 6.1 you can point a block at a render.php file from block.json, so there is no need to write a separate render_callback.
In this tutorial you will build a "Last Updated" block that prints the modified date of the post it sits in. It works inside a single post and inside a Query Loop in a block theme, because it reads the post ID from block context. The code targets the current release, WordPress 7.1.3 (October 2026), and the official @wordpress/create-block scaffolding.
What you need before you start
- A local WordPress site. Any local stack works. The official tutorial uses
wp-env, which runs WordPress in Docker;create-blockcan add its configuration for you with the--wp-envflag. - Node.js and npm. The
create-blockdocumentation states a minimum of Node 20.10.0, but the build tool it installs,@wordpress/scripts(version 36.1.0 on npm today), declares support for Node^22.22.2,^24.15.0or 26 and later. Use a current LTS release such as Node 24 and you meet both. - PHP 8.3 or later on the server is what WordPress.org recommends. WordPress still runs on PHP 7.4, but that version is end-of-life.
Step 1: Scaffold a dynamic block plugin
From your site's wp-content/plugins folder, run create-block with your own namespace and the dynamic variant. The namespace becomes the first half of the block name (acme/last-updated), so pick one that is unique to you or your company.
cd wp-content/plugins
npx @wordpress/create-block@latest last-updated --namespace=acme --variant=dynamic
cd last-updated
npm startnpm start watches the src folder and rebuilds into build whenever you save. Activate the "Last Updated" plugin from Plugins in wp-admin and the block will appear in the inserter.
The scaffold gives you a plugin file, a src/ folder with block.json, index.js, edit.js, render.php and stylesheets, and a package.json wired to @wordpress/scripts. Because you chose the dynamic variant there is no save.js: the block saves no markup of its own.
How the plugin registers the block
Open the main plugin file. Current versions of create-block register blocks from a generated manifest instead of reading each block.json at runtime, which is cheaper on every request. The relevant part looks like this:
<?php
/**
* Plugin Name: Last Updated
* Description: A dynamic block that prints the last-modified date of the current post.
* Version: 0.1.0
* Requires at least: 6.8
* Requires PHP: 7.4
* Text Domain: last-updated
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
function acme_last_updated_block_init() {
wp_register_block_types_from_metadata_collection(
__DIR__ . '/build',
__DIR__ . '/build/blocks-manifest.php'
);
}
add_action( 'init', 'acme_last_updated_block_init' );wp_register_block_types_from_metadata_collection() arrived in WordPress 6.8, so the Requires at least header above is set to 6.8. If you must support older sites, register with register_block_type( __DIR__ . '/build/last-updated' ) instead.
Step 2: Describe the block in block.json
block.json is the single source of truth for a block's name, attributes, supports and assets. Replace the generated file in src/ with the version below.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "acme/last-updated",
"version": "0.1.0",
"title": "Last Updated",
"category": "theme",
"icon": "update",
"description": "Shows the date the current post or page was last modified.",
"usesContext": [ "postId", "postType" ],
"attributes": {
"prefix": {
"type": "string",
"default": "Last updated:"
},
"showTime": {
"type": "boolean",
"default": false
}
},
"supports": {
"html": false,
"color": {
"text": true,
"background": true
},
"typography": {
"fontSize": true
},
"spacing": {
"margin": true,
"padding": true
}
},
"textdomain": "last-updated",
"editorScript": "file:./index.js",
"render": "file:./render.php"
}The important lines are:
"apiVersion": 3means the block is written to work inside the iframed editor. Since WordPress 7.1 the post editor is always iframed, regardless of the API version of blocks in the content, so this is no longer optional in practice."usesContext": [ "postId", "postType" ]asks WordPress to pass the surrounding post's ID and type into the block. Core blocks such as Date (core/post-date) declare the same context. Inside a Query Loop the values change for each post, which is exactly what you want."supports"switches on core colour, font-size and spacing controls. The block editor handbook recommends using native supports before building custom controls, because users get the same interface as in core blocks and you write no extra code."render": "file:./render.php"tells WordPress to include this PHP file to produce the front-end HTML.
The scaffold also adds editorStyle, style and sometimes viewScript entries. This block needs no custom CSS or front-end JavaScript, so you can delete those entries and the matching files, or leave them if you plan to style the block later.

Step 3: Build the editor view in edit.js
The editor needs to show something close to the front-end output and give the user two settings: the text in front of the date, and whether to include the time. Replace src/edit.js with:
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl, ToggleControl } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { dateI18n, getSettings } from '@wordpress/date';
export default function Edit( { attributes, setAttributes, context } ) {
const { prefix, showTime } = attributes;
const { postId, postType } = context;
// Reads the "modified" field of the post this block sits in.
const [ modified ] = useEntityProp( 'postType', postType, 'modified', postId );
const { formats } = getSettings();
const format = showTime ? `${ formats.date } ${ formats.time }` : formats.date;
const display = modified
? dateI18n( format, modified )
: __( '(date of the current post)', 'last-updated' );
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Settings', 'last-updated' ) }>
<TextControl
label={ __( 'Text before the date', 'last-updated' ) }
value={ prefix || '' }
onChange={ ( value ) => setAttributes( { prefix: value } ) }
/>
<ToggleControl
label={ __( 'Show the time as well', 'last-updated' ) }
checked={ !! showTime }
onChange={ () => setAttributes( { showTime: ! showTime } ) }
/>
</PanelBody>
</InspectorControls>
<p { ...useBlockProps() }>
{ prefix } <time>{ display }</time>
</p>
</>
);
}A few notes on this code:
useBlockProps()adds the classes and inline styles produced by the supports you enabled. Without it, colour and font-size choices would not show in the editor.useEntityProp()from@wordpress/core-datareturns a property of an entity record. Passing thepostTypeandpostIdfrom context makes it read the right post, including each item in a Query Loop.getSettings().formatsfrom@wordpress/dateholds the date and time formats configured under Settings → General, so the editor matches what PHP prints.- When the block is placed in a template with no post (for example while editing a generic template in the Site Editor), there is no
modifiedvalue, so a placeholder is shown.
The build tool detects the @wordpress/* imports and declares them as script dependencies automatically, so you do not need to bundle these packages yourself.
Step 4: Render the front end in render.php
WordPress includes render.php with three variables in scope: $attributes, $content and $block (a WP_Block instance whose context property holds the values you asked for). Replace src/render.php with:
<?php
/**
* Front-end output for the acme/last-updated block.
*
* Variables provided by WordPress:
* @var array $attributes Block attributes.
* @var string $content Saved inner content (empty for this block).
* @var WP_Block $block Block instance, including context.
*/
$post_id = isset( $block->context['postId'] ) ? (int) $block->context['postId'] : get_the_ID();
if ( ! $post_id ) {
return;
}
$format = get_option( 'date_format' );
if ( ! empty( $attributes['showTime'] ) ) {
$format .= ' ' . get_option( 'time_format' );
}
$modified = get_the_modified_date( $format, $post_id );
if ( ! $modified ) {
return;
}
$prefix = isset( $attributes['prefix'] ) ? $attributes['prefix'] : '';
?>
<p <?php echo get_block_wrapper_attributes(); ?>>
<?php echo esc_html( $prefix ); ?>
<time datetime="<?php echo esc_attr( get_the_modified_date( 'c', $post_id ) ); ?>"><?php echo esc_html( $modified ); ?></time>
</p>This file follows the core security rules for plugin code: never trust data, escape as late as possible. The prefix comes from the post content, so it is printed with esc_html(); the machine-readable date goes into an attribute through esc_attr(). get_block_wrapper_attributes() is the PHP counterpart of useBlockProps() and outputs the classes and styles from block supports.
Two details are easy to miss:
- The handbook warns that
render.phpis loaded for every instance of the block on a page. Do not declare functions or classes in it; put shared helpers in a separate file that the plugin loads once. - Returning early outputs nothing, which is the right behaviour when there is no post. Do not echo an error message to visitors.
Step 5: Test it in the editor and on the front end
With npm start still running, open a post, insert Last Updated and check the following:
| Check | Expected result |
|---|---|
| Change the prefix in the sidebar | Editor and front end both show the new text |
| Turn on "Show the time as well" | Date and time appear, in the formats from Settings → General |
| Edit and update the post, then reload the front end | The date changes without re-saving the block |
| Add the block inside a Query Loop in a block theme template | Each post in the loop shows its own modified date |
| Pick a text colour and font size | Styles appear in the editor and on the front end |
| Deactivate the plugin | The block no longer renders; content elsewhere is unaffected |
Look at the post in the Code editor view. You should see only a comment such as <!-- wp:acme/last-updated {"showTime":true} /-->. That is the defining property of a dynamic block: the database stores attributes, not markup, so changing render.php updates every instance on the site immediately and never triggers block validation errors.

Step 6: Build and ship the plugin
When you are happy with the block, stop the watcher and create a production build and an installable archive. Both scripts are provided by @wordpress/scripts:
npm run build
npm run plugin-zipplugin-zip creates a zip of the plugin, choosing files according to Plugin Handbook practice by default; you can control the list exactly with a files field in package.json. Open the zip once and confirm it contains the main PHP file and the build folder, and not node_modules. Upload that zip through Plugins → Add New, or deploy the folder with your usual tooling.
Common problems and fixes
- The block shows "This block has encountered an error". Check the browser console. A missing import or a typo in an attribute name is the usual cause. Make sure the attribute names in
edit.jsmatchblock.jsonexactly. - The front end is empty. Confirm that
renderinblock.jsonpoints atfile:./render.phpand that the file was copied intobuild/last-updated/by the build. If you editedrender.phpoutsidesrc/, your changes are overwritten on the next build. - "Cannot redeclare function" fatal errors. You declared a function inside
render.phpand the block appears twice on the page. Move the function to the main plugin file. - The editor shows the placeholder inside a post. The post may not be saved yet. Save a draft so the post has a modified date.
Next steps
You now have a working dynamic block that uses block.json, block context, native supports and a render.php template, scaffolded and built with the official tools. From here you can:
- Add a format attribute so editors can choose a date format per block.
- Add a
savefunction that stores fallback HTML, which the handbook describes as a way to keep a readable version if the plugin is ever removed. - Read the iframe migration guide in the block editor handbook if you maintain older blocks; since WordPress 7.1 every post editor session runs in an iframe.
Keep the official tutorial and the block.json reference open while you work; both are updated with each WordPress release.
Frequently asked questions
What is the difference between a static and a dynamic WordPress block?
A static block saves its final HTML into the post when you save. A dynamic block saves only its comment delimiter and attributes, and PHP generates the HTML on each request, so changes to data or markup appear everywhere at once without re-saving posts.
Do I still need a render_callback for dynamic blocks?
Not if you register the block from block.json. Since WordPress 6.1 the
renderproperty can point at a PHP file such asrender.php. Arender_callbackpassed toregister_block_type()is still supported and is needed for blocks registered only in PHP.Which Node.js version should I use with @wordpress/create-block?
create-block itself needs Node 20.10.0 or later, but the @wordpress/scripts version it installs (36.1.0 at the time of writing) declares Node ^22.22.2, ^24.15.0 or 26+. A current Node LTS release such as Node 24 satisfies both.
Should custom blocks live in a theme or a plugin?
The create-block documentation recommends a plugin, because blocks registered by a plugin keep working when the site changes theme. Content that depends on a theme's block would break after a theme switch.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- @wordpress/create-block — WordPress Developer Resources, 2026-09-09
- Tutorial: Build your first block — WordPress Developer Resources
- Metadata in block.json — WordPress Developer Resources
- Static or Dynamic rendering of a block — WordPress Developer Resources
- Block API Versions — WordPress Developer Resources, 2026-07-24
- Context (block API) — WordPress Developer Resources
- @wordpress/scripts — WordPress Developer Resources
- WordPress 7.1 “Mary Lou” — WordPress.org News, 2026-08-19
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Resource guideWordPress
WordPress security hardening checklist for 2026 (WordPress 7.1)
A practical WordPress 7.1 hardening checklist based on the official handbook: updates, two-step logins, wp-config.php constants, file permissions, database privileges, firewalls, WP-CLI checksum checks and tested backups.
By Cloudsoft Zone

TutorialWordPress
How to update WordPress safely with WP-CLI: backup, update, verify, roll back
A repeatable WordPress update routine with WP-CLI: check pending updates, export the database and archive files, update core, plugins, themes and translations in order, verify checksums, and restore if something breaks.
By Cloudsoft Zone

Troubleshooting guideCodeIgniter
CodeIgniter 4 validation not working? 9 causes and fixes
Fix CodeIgniter 4 validation problems fast: wrong input source, lost errors after redirect, permit_empty, strict JSON rules, is_unique placeholders and more.
By Cloudsoft Zone
