Explore how to build custom WordPress blocks using the latest REST API features, enhancing your development skills and site functionality.

Dynamic blocks solve a very specific WordPress problem: content that should stay current without forcing editors to re-save every page. A “Recent Posts by Category” block is a good example because the editor chooses a category once, and the front end keeps updating as new posts are published. If you need boilerplate while following along, generate common WordPress snippets here; it’s especially handy once you start adding REST routes or theme-side helpers.
The build here is intentionally narrow: one plugin, one block, one existing REST endpoint, one server-rendered output path. You’ll need WordPress 6.1 or later, a local WP install, Node.js with npm, and @wordpress/scripts available so the block assets can compile cleanly.
You need a custom Gutenberg block REST API workflow that does more than store static markup in post content. Imagine an editorial team building a homepage section called “Latest News,” but each page editor wants to choose a different category from the block sidebar. Hardcoding the list into saved HTML fails immediately: new posts won’t appear until someone opens the page and updates it again.
That’s where a dynamic block earns its keep. The editor still gets a live preview in Gutenberg, but the front end is rendered in PHP on every page load, so the list stays fresh. For this walkthrough, the block will:
block.jsonuseSelect and apiFetchrender.php/wp/v2/posts endpoint instead of a custom routeBefore wiring anything up, set the plugin folder up like this:
recent-posts-block/
├── block.json
├── plugin.php
├── render.php
└── src/
├── edit.js
└── index.jsThat file tree matters because block.json will point at index.js, and the render callback will point at render.php. The missing piece in the tree is your build output, which @wordpress/scripts will create for you.
A typical setup command flow looks like this from the plugin directory:
npm init -y
npm install @wordpress/scripts --save-devThen add a basic package.json script:
{
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
}
}And create src/index.js so the editor can register the block entry point:
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import metadata from '../block.json';
registerBlockType(metadata.name, {
edit: Edit,
save: () => null,
});That save: () => null line is not optional in this pattern. It tells WordPress the block is dynamic and that the saved content should not be static JSX markup. The actual front-end output will come from PHP.
block.jsonStart with the block definition. This is the piece that tells WordPress where the editor script lives, what attributes exist, and which PHP file should render the output. WordPress 6.1 or later is required here because the render key in block.json is part of the modern dynamic-block workflow.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-plugin/recent-posts",
"title": "Recent Posts by Category",
"category": "widgets",
"editorScript": "file:./index.js",
"render": "file:./render.php",
"attributes": {
"categoryId": {
"type": "number",
"default": 0
}
}
}A few implementation details matter here:
apiVersion: 3 keeps the block aligned with current editor APIs.editorScript points to the built JS entry generated from src/index.js.render: "file:./render.php" is what makes this a dynamic block.categoryId is the only attribute you need for this version of the block.Because rendering is delegated to PHP, there is no JavaScript save function returning markup. The editor stores the block comment and attributes, and WordPress calls the render file later.
Now for the part that makes the block feel useful in Gutenberg: sidebar controls plus a live preview. The editor needs two data sources:
Use useSelect to read categories from core data, and apiFetch inside useEffect to request posts whenever the selected category changes. Before writing your fetch logic, it helps to use the JSON Formatter to inspect the response structure before writing your apiFetch call, especially if you’re tweaking _fields, custom taxonomies, or custom endpoints later.
Here’s the complete src/edit.js:
import { useState, useEffect } from '@wordpress/element';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, SelectControl, Spinner } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import apiFetch from '@wordpress/api-fetch';
export default function Edit({ attributes, setAttributes }) {
const { categoryId } = attributes;
const [posts, setPosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const categories = useSelect((select) =>
select('core').getEntityRecords('taxonomy', 'category', { per_page: -1 })
);
useEffect(() => {
setIsLoading(true);
const path = categoryId
? `/wp/v2/posts?categories=${categoryId}&per_page=5`
: '/wp/v2/posts?per_page=5';
apiFetch({ path }).then((data) => {
setPosts(data);
setIsLoading(false);
});
}, [categoryId]);
const categoryOptions = categories
? [{ label: 'All', value: 0 }, ...categories.map((c) => ({ label: c.name, value: c.id }))]
: [{ label: 'Loading…', value: 0 }];
return (
<>
<InspectorControls>
<PanelBody title="Settings">
<SelectControl
label="Category"
value={categoryId}
options={categoryOptions}
onChange={(val) => setAttributes({ categoryId: Number(val) })}
/>
</PanelBody>
</InspectorControls>
<div {...useBlockProps()}>
{isLoading ? (
<Spinner />
) : (
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={post.link}>{post.title.rendered}</a>
</li>
))}
</ul>
)}
</div>
</>
);
}This does three jobs:
A couple of practical notes:
The default categoryId is 0, which means “All.” On first load, the effect requests /wp/v2/posts?per_page=5. That gives the block a useful preview before the editor touches any settings.
SelectControl values to numbersSelectControl returns strings. If you skip Number(val), your attribute becomes a string and you’ll eventually end up debugging why your PHP cast or REST query doesn’t match what you expected.
useSelect may return null before category records are available. That’s why the code falls back to a temporary Loading… option. You can make this fancier, but for a practical block plugin, this is enough.
If the preview request starts failing, check the response in the browser network tab and compare the status code against the HTTP status reference. A 401, 403, or 500 tells you very different stories, and it’s faster to diagnose from the code than from a blank spinner.
The editor preview is only half the job. The actual front-end markup should come from PHP so the list updates whenever content changes. That’s the reason to use a dynamic block in the first place.
Create render.php with this exact code:
<?php
$category_id = isset( $attributes['categoryId'] ) ? (int) $attributes['categoryId'] : 0;
$args = [
'post_type' => 'post',
'posts_per_page' => 5,
'post_status' => 'publish',
];
if ( $category_id ) {
$args['cat'] = $category_id;
}
$query = new WP_Query( $args );
if ( $query->have_posts() ) :
?>
<ul <?php echo get_block_wrapper_attributes(); ?>>
<?php while ( $query->have_posts() ) : $query->the_post(); ?>
<li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
<?php endwhile; wp_reset_postdata(); ?>
</ul>
<?php endif;This file reads the stored block attribute, builds a WP_Query, and outputs a simple list of links. The get_block_wrapper_attributes() call keeps the block wrapper compatible with editor-generated classes and spacing support.
The key behavior is architectural, not cosmetic: because your JavaScript registration uses save: () => null, WordPress does not rely on saved HTML in post content. Instead, it calls this render file on each page load. That gives you:
If you need slightly richer markup later—dates, excerpts, featured images—add them here, not in a JavaScript save function. Keep the editor preview lightweight and the front end authoritative.
The plugin bootstrap is refreshingly small because block.json does almost everything already. You don’t need manual script registration, dependency arrays, or custom enqueue hooks for this base setup.
Create plugin.php:
<?php
/**
* Plugin Name: Recent Posts Block
* Plugin URI: https://example.com
* Version: 1.0.0
*/
add_action( 'init', function () {
register_block_type( __DIR__ );
} );That’s it. register_block_type( __DIR__ ) tells WordPress to look for block.json in the plugin root, and from there it resolves:
At this point, your plugin is structurally complete. Run your build process, activate the plugin, insert the block, and you should be able to choose a category in the sidebar and see the preview update.
This approach is solid, but there are a few edges worth handling deliberately.
Your editor preview uses /wp/v2/posts, while the front end uses WP_Query. In this example they line up closely, but once you add sticky post behavior, custom ordering, meta queries, or custom post types, it’s easy for the editor and front end to diverge.
The fix is simple: keep both sides logically equivalent. If PHP uses a taxonomy filter, make sure the REST request does too. If PHP limits to published posts, don’t preview drafts in JS unless that mismatch is intentional.
The provided edit.js handles loading, but not fetch errors. For production use, add a .catch() branch and render a small error notice inside the block. Otherwise a failed request can leave the editor stuck in a spinner state.
A practical enhancement looks like this:
useEffect(() => {
setIsLoading(true);
const path = categoryId
? `/wp/v2/posts?categories=${categoryId}&per_page=5`
: '/wp/v2/posts?per_page=5';
apiFetch({ path })
.then((data) => {
setPosts(data);
setIsLoading(false);
})
.catch(() => {
setPosts([]);
setIsLoading(false);
});
}, [categoryId]);/wp/v2/posts won’t cover every data shapeFor this walkthrough, using the existing posts endpoint is the right scope because it keeps the focus on block mechanics. But eventually you may need data that doesn’t map neatly to a standard post query: featured flags in post meta, external API responses, aggregated analytics, or mixed content from several sources.
That’s when a custom REST endpoint becomes useful. Add it in functions.php or a plugin bootstrap file, then point apiFetch at your namespace. If you want to generate the register_rest_route() boilerplate instead of typing it from scratch, that builder saves a few minutes and reduces typo hunting.
Here’s the optional endpoint exactly as a starting point:
add_action( 'rest_api_init', function () {
register_rest_route( 'my-plugin/v1', '/featured-posts', [
'methods' => 'GET',
'callback' => function () {
$posts = get_posts([
'numberposts' => 5,
'meta_key' => '_is_featured',
'meta_value' => '1',
]);
return array_map( fn( $p ) => [
'id' => $p->ID,
'title' => get_the_title( $p ),
'url' => get_permalink( $p ),
], $posts );
},
'permission_callback' => '__return_true',
] );
} );And the matching apiFetch path in your edit component would become:
apiFetch({ path: '/my-plugin/v1/featured-posts' }).then((data) => {
setPosts(data);
setIsLoading(false);
});That version is useful when the editor preview needs data that /wp/v2/posts can’t express cleanly. Just make sure your PHP front-end rendering follows the same rules as your custom endpoint, or you’ll reintroduce the preview/front-end mismatch problem.
A lot of Gutenberg friction comes from overengineering too early. For a block like this, resist the urge to split every concern into separate abstractions before the first version works. One block.json, one edit.js, one render.php, one plugin.php is enough. Once the block is stable, then add richer controls, transforms, InnerBlocks, or support for custom post types and taxonomies.
You now have a complete custom gutenberg block REST API setup: a block plugin registered through block.json, a live editor preview powered by apiFetch, and server-rendered output that stays fresh without re-saving pages. From here, the obvious next extensions are block transforms, InnerBlocks layouts, or adapting the query to custom post types and taxonomies. When you’re ready to branch into custom routes, generate the register_rest_route() boilerplate, and use the JSON Formatter to inspect endpoint payloads before wiring them into your block.