A dynamic Gutenberg block with block.json and render.php

Contents
- What the create-block scaffold produces today
- block.json as the contract between editor and server
- Registration through a manifest instead of single block.json files
- render.php and its three variables
- The editor side: edit.js with ServerSideRender
- Static or dynamic: what ends up in the database
- Limits and open points
- Questions and answers
- Sources
A block whose output depends on data that changes after the post was saved (the newest posts, a price, a list of events) cannot store its final HTML in post_content. Every new post in the category would leave the saved markup behind. Static blocks have a second weakness: once the markup of the save function changes, older posts no longer match, the editor reports invalid content and the block needs a deprecation entry. A dynamic block avoids both problems. The editor stores only the block comment with its attributes, and PHP renders the markup on every request.
This article builds such a block from scratch: a list of the latest posts of one category, with the attributes categoryId and count. It describes what @wordpress/create-block generates as of 29.09.2026, how block.json connects the editor with the server, why the scaffold now registers blocks through a generated manifest, and which variables render.php receives. All code is complete; the plugin runs on WordPress 6.9 or newer, and the current release at the time of writing is 7.1.2.
What the create-block scaffold produces today
The official tool is the npm package @wordpress/create-block; the latest published version on the retrieval date is 4.99.0 from 23.09.2026. It needs Node.js 20.10.0 or newer. The standard template knows two variants, static and dynamic. The dynamic variant differs in exactly three places: block.json receives a render field pointing to render.php, index.js registers the block without a save function, and the npm scripts copy PHP files into the build folder. The commands for the example look like this:
npx @wordpress/create-block@latest category-posts --variant dynamic --namespace lw --title "Category Posts"
cd category-posts
npm install @wordpress/components @wordpress/core-data @wordpress/data @wordpress/html-entities @wordpress/server-side-render --save
npm startThe additional npm install line is not strictly required for the build, because @wordpress/scripts treats @wordpress/* imports as externals and maps them to the script handles that WordPress already ships. Installing the packages keeps editor tooling and linting aware of the imports. npm start watches src/ and writes the result to build/; npm run build produces the minified version for release. The resulting plugin folder looks roughly like this:
category-posts/ (abridged)
├── category-posts.php plugin header and block registration
├── package.json build/start scripts with --blocks-manifest
├── readme.txt
├── src/
│ └── category-posts/
│ ├── block.json
│ ├── index.js
│ ├── edit.js
│ ├── render.php
│ ├── view.js not needed here, deleted
│ ├── style.scss
│ └── editor.scss
└── build/ written by npm start / npm run build
├── blocks-manifest.php
└── category-posts/
├── block.json
├── index.js
├── index.asset.php
├── index.css
├── style-index.css
└── render.phpWith the default template, the block lives in src/<slug>/, not directly in src/. The text domain defaults to the slug, here category-posts. The scaffold also adds a view.js file and a matching viewScript entry for front-end JavaScript. A server-rendered list without interaction does not need it, so the example removes both. The two npm scripts that matter for a dynamic block are generated like this:
{
"scripts": {
"build": "wp-scripts build --webpack-copy-php --blocks-manifest",
"start": "wp-scripts start --webpack-copy-php --blocks-manifest"
}
}--webpack-copy-php copies all PHP files from src to build; without the flag, @wordpress/scripts only copies PHP files that a block.json names in render or variations. --blocks-manifest writes build/blocks-manifest.php, which the next sections explain. The generated plugin header states “Requires at least: 6.8” and readme.txt states “Tested up to: 6.8”. Both are template defaults, not statements about the current release, and they should be adjusted before publishing; the header fields are covered in a separate article of this series about readme.txt, the plugin header and SVN.
block.json as the contract between editor and server
block.json is read on both sides. PHP uses it to register the block type, its attributes and its assets; the editor bundle imports the same file so that JavaScript knows the name and the attribute schema. For the example, the file in src/category-posts/ has this content:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "lw/category-posts",
"version": "0.1.0",
"title": "Category Posts",
"category": "widgets",
"icon": "list-view",
"description": "Lists the latest posts of one category, rendered on the server.",
"attributes": {
"categoryId": {
"type": "integer",
"default": 0
},
"count": {
"type": "integer",
"default": 5
}
},
"usesContext": [ "postId" ],
"supports": {
"html": false,
"align": [ "wide", "full" ],
"color": {
"background": true,
"text": true,
"link": true
},
"spacing": {
"margin": true,
"padding": true
},
"typography": {
"fontSize": true
}
},
"textdomain": "category-posts",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"render": "file:./render.php"
}apiVersion 3 is the current Block API version and was introduced with WordPress 6.3. Without the field, WordPress assumes version 1. Version 3 is tied to the iframed post editor: since 6.3, the editor used an iframe only when every registered block declared version 3 or higher. WordPress 7.0 changed the check to the blocks actually present in the post content, and according to the Block API documentation, WordPress 7.1 always uses the iframe for the post editor regardless of apiVersion. For a new block this leaves one consequence: editor styles must work inside the iframe, which they do when they are loaded through editorStyle and style in block.json.
Both attributes have type integer and default values. Before rendering, WordPress validates each attribute against its schema, drops values that fail validation and fills in defaults for missing ones. usesContext requests the postId context, which render_block() supplies from the global post. This lets the block exclude the post that contains it.
The supports section enables alignment, colours, spacing and font size. get_block_wrapper_attributes() and useBlockProps() produce the markup for those settings later, so no custom code is needed. editorScript and editorStyle load only in the editor, style loads in both the editor and the front end, and render is a server-only field.
Registration through a manifest instead of single block.json files
For a long time, register_block_type( __DIR__ . '/build' ) or a path to the block folder was the usual registration. It still works and is the right choice for a single block without a build step. On every request, however, it reads a JSON file from disk and decodes it. WordPress 6.7 added wp_register_block_metadata_collection(), which registers a PHP manifest with the metadata of several blocks; the dev note by Felix Arntz names the avoided file system and JSON operations as the reason. In 6.7 each block still needed its own register_block_type() call. WordPress 6.8 closed that gap with wp_register_block_types_from_metadata_collection(): one call registers the collection and all block types in it.
The current scaffold uses exactly this function whenever @wordpress/scripts is enabled, which is the default. The main plugin file of the example therefore looks like this:
/**
* Plugin Name: Category Posts
* Description: Server-rendered block that lists the latest posts of one category.
* Version: 0.1.0
* Requires at least: 6.9
* Requires PHP: 7.4
* Author: Lukas Wojcik
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: category-posts
*
* @package Lw
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Registers every block listed in build/blocks-manifest.php.
*
* The manifest is generated by wp-scripts (--blocks-manifest), so WordPress
* does not have to read and decode each block.json on every request.
*/
function lw_category_posts_block_init() {
wp_register_block_types_from_metadata_collection(
__DIR__ . '/build',
__DIR__ . '/build/blocks-manifest.php'
);
}
add_action( 'init', 'lw_category_posts_block_init' );The first parameter is the base folder of the collection, the second the manifest. If the second parameter is left empty, the base folder must refer to a collection registered earlier. Plugins that still support WordPress 6.7 or older need a function_exists() check with a fallback, as described in the 6.8 dev note. The example avoids this through “Requires at least: 6.9”, which it needs anyway for the editor import shown below. Without a build step, a single call is enough:
// Single block, without a manifest: reads build/category-posts/block.json.
register_block_type( __DIR__ . '/build/category-posts' );One detail is easy to miss: the manifest is a build artefact. A block.json changed in src/ only takes effect after the next build, even on the server side.
render.php and its three variables
When block.json contains a render field, WordPress creates a render_callback during registration. In the core source this is a static closure that starts output buffering, loads the file with require and returns the buffer. Inside the file, three variables are available: $attributes (array of the block attributes, defaults already applied), $content (the saved inner content, relevant for blocks with inner blocks and always empty here) and $block (the WP_Block instance with name, context and the parsed block). The render file of the example:
/**
* Server-side rendering of the lw/category-posts block.
*
* Variables provided by WordPress:
*
* @var array $attributes Block attributes, defaults from block.json applied.
* @var string $content Inner content (always empty for this block).
* @var WP_Block $block Block instance: name, context, parsed block.
*
* @package Lw
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
$lw_category_id = isset( $attributes['categoryId'] ) ? absint( $attributes['categoryId'] ) : 0;
$lw_count = isset( $attributes['count'] ) ? absint( $attributes['count'] ) : 5;
$lw_count = min( max( $lw_count, 1 ), 10 );
$lw_term = $lw_category_id ? get_term( $lw_category_id, 'category' ) : null;
// Render nothing until a valid category has been chosen.
if ( ! $lw_term instanceof WP_Term ) {
return;
}
// Do not list the post that contains the block (context from "usesContext").
$lw_current_id = isset( $block->context['postId'] ) ? absint( $block->context['postId'] ) : 0;
$lw_posts = get_posts(
array(
'category' => $lw_term->term_id,
'numberposts' => $lw_count,
'post_status' => 'publish',
'has_password' => false,
'exclude' => $lw_current_id ? array( $lw_current_id ) : array(),
)
);
<div echo wp_kses_data( get_block_wrapper_attributes() ); >
if ( empty( $lw_posts ) ) :
<p> esc_html_e( 'No posts in this category yet.', 'category-posts' ); </p>
else :
<ul class="wp-block-lw-category-posts__list">
foreach ( $lw_posts as $lw_post ) :
<li>
<a href="<?php echo esc_url( get_permalink( $lw_post ) ); ?>"> echo esc_html( get_the_title( $lw_post ) ); </a>
<time datetime="<?php echo esc_attr( get_the_date( 'c', $lw_post ) ); ?>"> echo esc_html( get_the_date( '', $lw_post ) ); </time>
</li>
endforeach;
</ul>
endif;
</div>Several decisions in this file follow from how it is loaded. Because require runs once for every block instance on a page, the file must not declare functions or classes; a second instance of the block would end in a fatal error. Helper functions belong in the main plugin file or in a separate include. The variables look global to PHP_CodeSniffer, which is why they carry the prefix lw_. That prefix does not satisfy the check, however: WPCS rejects prefixes shorter than four characters, and Plugin Check 2.1.0, which derives the expected prefix from the plugin code (here from the function name lw_category_posts_block_init), lists every one of these variables as a warning. The warnings do not affect how the block works. return ends the file early and leaves an empty buffer, which is how a block renders nothing.
Attributes are data from post_content and therefore input. The schema check guarantees integers, not a reasonable range, so absint() together with min() and max() limits the count to 1 to 10. get_term() confirms that the category exists. get_posts() is a thin wrapper around WP_Query that sets ignore_sticky_posts, no_found_rows and suppress_filters itself, which fits a short list without pagination. How further query arguments such as meta_query turn into SQL is shown in the article on meta_query in WP_Query. Every output passes through the matching escaping function: esc_url() for the link, esc_html() for title and date, esc_attr() for the datetime attribute. The broader rules are the topic of a separate article in this series on sanitizing, escaping, nonces and capabilities.
get_block_wrapper_attributes() returns the attribute string for the wrapper: the class wp-block-lw-category-posts plus classes and inline styles from the supports settings. The function escapes every value with esc_attr() itself, as its source shows. The WordPress Coding Standards list of auto-escaped functions does not contain it, however, so a bare echo triggers the escaping sniff; Plugin Check 2.1.0 reports it as an error (WordPress.Security.EscapeOutput.OutputNotEscaped). wp_kses_data() leaves an attribute string without tags unchanged and satisfies the check; a justified phpcs:ignore comment is the alternative.
The editor side: edit.js with ServerSideRender
In JavaScript, index.js registers the block with edit only. When no save is given, the block API uses a default save that returns null:
import { registerBlockType } from '@wordpress/blocks';
import './style.scss';
import Edit from './edit';
import metadata from './block.json';
// No save(): the default save returns null, so only the block comment
// with its attributes is stored in post_content.
registerBlockType( metadata.name, {
edit: Edit,
} );edit.js provides the settings sidebar and a preview. Categories come from the core data store, with the same query the core Latest Posts block uses. The preview is produced by ServerSideRender, which sends the attributes to the REST route /wp/v2/block-renderer/lw/category-posts and inserts the HTML that render.php returns:
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import {
PanelBody,
Placeholder,
RangeControl,
SelectControl,
Spinner,
} from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { decodeEntities } from '@wordpress/html-entities';
import { ServerSideRender } from '@wordpress/server-side-render';
import metadata from './block.json';
import './editor.scss';
// Same query shape as the core Latest Posts block uses for its category list.
const CATEGORIES_QUERY = { per_page: -1, _fields: 'id,name', context: 'view' };
export default function Edit( { attributes, setAttributes } ) {
const { categoryId, count } = attributes;
const blockProps = useBlockProps();
const categories = useSelect(
( select ) =>
select( coreStore ).getEntityRecords(
'taxonomy',
'category',
CATEGORIES_QUERY
),
[]
);
const options = [
{ value: '0', label: __( '— Select a category —', 'category-posts' ) },
...( categories ?? [] ).map( ( term ) => ( {
value: String( term.id ),
label: decodeEntities( term.name ),
} ) ),
];
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Settings', 'category-posts' ) }>
{ categories ? (
<SelectControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Category', 'category-posts' ) }
value={ String( categoryId ) }
options={ options }
onChange={ ( value ) =>
setAttributes( { categoryId: Number( value ) } )
}
/>
) : (
<Spinner />
) }
<RangeControl
__next40pxDefaultSize
__nextHasNoMarginBottom
label={ __( 'Number of posts', 'category-posts' ) }
value={ count }
min={ 1 }
max={ 10 }
onChange={ ( value ) =>
setAttributes( { count: value ?? 5 } )
}
/>
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
{ categoryId ? (
<ServerSideRender
block={ metadata.name }
attributes={ attributes }
skipBlockSupportAttributes
/>
) : (
<Placeholder
icon="list-view"
label={ __( 'Category Posts', 'category-posts' ) }
instructions={ __(
'Choose a category in the block settings.',
'category-posts'
) }
/>
) }
</div>
</>
);
}In the package version bundled with WordPress 6.9 (6.9.5), @wordpress/server-side-render already exports ServerSideRender as a named export; the default export is marked deprecated and survives as a compatibility layer. This named import is the reason for “Requires at least: 6.9” in the plugin header. skipBlockSupportAttributes removes the style attributes from the request, because the outer div already receives colours and spacing through useBlockProps() and they would otherwise appear twice. The component also appends the current post ID to the request, so the postId context in render.php works in the editor as well. The two __next… props opt older WordPress versions into the current control size and spacing; according to the components changelog, both are now defaults and can be dropped once those versions are no longer supported.
The diagram summarises who reads which file and when: PHP reads the manifest on every request, the editor loads the script bundle and both stylesheets, and render.php runs both for the front end and for the editor preview through the REST route.
Static or dynamic: what ends up in the database
For the example, the database contains nothing but the block comment. Attributes that equal their default are not serialised:
<!-- wp:lw/category-posts {"categoryId":7,"count":3} /-->This is the core of the difference. A static block stores the output of its save function as HTML between the comment delimiters, and the editor compares that HTML with a fresh save call when the post is opened. Any change to the markup (an extra class, a wrapper, a different tag) therefore breaks existing content unless a deprecated array describes the old versions. With a dynamic block, save returns null, the editor skips markup validation, and a change to render.php reaches every occurrence of the block after the next deployment. The block editor handbook names exactly these two cases for dynamic rendering: content that should change without the post being updated, and markup that is expected to change.
Dynamic rendering adds work on the server: each page view containing the block runs a database query, and each attribute change in the editor triggers a REST request. This is usually acceptable for a short list. For output that is expensive to generate, a transient or the object cache can store the rendered HTML. Neither is included in the example, and no measurements were made for this article. The block also depends on the plugin being active: without it, the block renders nothing because post_content contains no fallback HTML.
Limits and open points
- The
ServerSideRenderdocumentation calls the component a fallback or legacy mechanism that is not appropriate for developing new features. For blocks whose preview can be built from REST data in JavaScript, client-side rendering is the recommended path. The example uses it deliberately so thatrender.phpremains the single source of the markup; the price is a nested wrapper in the editor (the outerdivfromuseBlockProps()contains thedivfromrender.php). - WordPress 7.0 introduced PHP-only block registration: with
'autoRegister' => trueundersupportsand arender_callback, a block appears in the editor without its own JavaScript, and controls are generated for string, integer, boolean and enum attributes. A category selection with names instead of numeric IDs is not covered by that, which is why the example keeps its ownedit.js. - The scaffold described here corresponds to
@wordpress/create-block4.99.0. The package is released frequently; file names, flags and defaults can change with later versions. - On WordPress 7.1.2, Plugin Check 2.1.0 reports no errors for the example (tested on 30.09.2026). For each copy of
render.phpinsrc/andbuild/it lists sevenPrefixAllGlobalswarnings and one warning about theexcludeparameter ofget_posts(); a bareecho get_block_wrapper_attributes()is reported as an error. - Caching of the rendered list and the performance cost of the REST preview were not measured.
Questions and answers
Why does a change to block.json have no effect although the file is saved?
Registration reads build/blocks-manifest.php, not src/. The manifest is a build artefact; PHP sees the new version only after npm run build or while npm start is running.
May render.php declare its own helper functions?
No. WordPress loads the file with require again for every block instance. With two instances on one page, the second declaration of the same function ends in a fatal error. Helper functions belong in the main plugin file or a separate include, with a prefix.
Does a server-rendered block still need JavaScript since WordPress 7.0?
Not necessarily. With 'autoRegister' => true under supports and a render_callback, a block appears in the editor without its own JavaScript, and controls are generated for string, integer, boolean and enum attributes. A dropdown with category names or a custom placeholder still requires an edit.js.
Sources
- @wordpress/create-block – Block Editor Handbook (retrieved 29.09.2026)
- create-block source: templates.js, init-package-json.js, plugin and block templates (GitHub) (retrieved 29.09.2026)
- @wordpress/create-block on npm, version 4.99.0 (retrieved 29.09.2026)
- Tutorial: Build your first block (retrieved 29.09.2026)
- Metadata in block.json (retrieved 29.09.2026)
- Block API Versions (retrieved 29.09.2026)
- Static or Dynamic rendering of a block (retrieved 29.09.2026)
- Registration of a block (retrieved 29.09.2026)
- Deprecation (retrieved 29.09.2026)
- @wordpress/scripts (retrieved 29.09.2026)
- Dependency Extraction Webpack Plugin (README) (retrieved 29.09.2026)
- @wordpress/server-side-render and its changelog (retrieved 29.09.2026)
- get_block_wrapper_attributes() (retrieved 29.09.2026)
- wp_register_block_types_from_metadata_collection() (retrieved 29.09.2026)
- New block type registration APIs to improve performance in WordPress 6.7 (retrieved 29.09.2026)
- More efficient block type registration in 6.8 (retrieved 29.09.2026)
- PHP-only block registration (retrieved 29.09.2026)
- wordpress-develop, branch 7.1: blocks.php and post.php (get_posts) (retrieved 29.09.2026)
- WordPress Coding Standards: EscapingFunctionsTrait.php (retrieved 29.09.2026)
- WordPress version check API (current release 7.1.2) (retrieved 29.09.2026)