Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
WordPressDevelopmentJavaScriptPHPTutorial

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.

Cloudsoft Zone

Updated 10 min read

WordPress contributors gathered in a large hall at the end of Contributor Day at WordCamp Asia 2025
Photograph: Robert Sim / CC BY-SA 4.0, via Wikimedia Commons
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-block can add its configuration for you with the --wp-env flag.
  • Node.js and npm. The create-block documentation 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.0 or 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.

Shell
cd wp-content/plugins
npx @wordpress/create-block@latest last-updated --namespace=acme --variant=dynamic
cd last-updated
npm start

npm 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
<?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.

JSON
{
	"$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": 3 means 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.

Computer monitor showing custom PHP source code in a text editor
Dynamic blocks render their front-end HTML in PHP on every request, so escaping output matters.

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:

JavaScript
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-data returns a property of an entity record. Passing the postType and postId from context makes it read the right post, including each item in a Query Loop.
  • getSettings().formats from @wordpress/date holds 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 modified value, 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
<?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.php is 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:

CheckExpected result
Change the prefix in the sidebarEditor 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 endThe date changes without re-saving the block
Add the block inside a Query Loop in a block theme templateEach post in the loop shows its own modified date
Pick a text colour and font sizeStyles appear in the editor and on the front end
Deactivate the pluginThe 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.

Attendees talking at round discussion tables in the reception hall at WordCamp Asia 2025
WordCamps are a common place to learn block development from the people who build the editor.

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:

Shell
npm run build
npm run plugin-zip

plugin-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.js match block.json exactly.
  • The front end is empty. Confirm that render in block.json points at file:./render.php and that the file was copied into build/last-updated/ by the build. If you edited render.php outside src/, your changes are overwritten on the next build.
  • "Cannot redeclare function" fatal errors. You declared a function inside render.php and 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 save function 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 render property can point at a PHP file such as render.php. A render_callback passed to register_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.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. @wordpress/create-block — WordPress Developer Resources, 2026-09-09
  2. Tutorial: Build your first block — WordPress Developer Resources
  3. Metadata in block.json — WordPress Developer Resources
  4. Static or Dynamic rendering of a block — WordPress Developer Resources
  5. Block API Versions — WordPress Developer Resources, 2026-07-24
  6. Context (block API) — WordPress Developer Resources
  7. @wordpress/scripts — WordPress Developer Resources
  8. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.