Gutenberg blocks are how WordPress wants you to build editor UI in 2025. The block API has stabilised, the tooling has matured, and a well-built block feels native to the editor in a way nothing else does.
Here's the build pattern I use for production blocks.
Project Layout
my-block/
├── block.json # Block metadata
├── src/
│ ├── index.js # Editor entry
│ ├── edit.js # Editor component
│ ├── save.js # Frontend output (or null for dynamic)
│ ├── editor.scss # Editor-only styles
│ └── style.scss # Frontend + editor styles
├── render.php # Server-side render (dynamic blocks)
└── my-block.php # Plugin bootstrap
@wordpress/scripts builds src/index.js to build/index.js and copies block.json. You don't need a custom webpack config.
block.json — The Single Source of Truth
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "alamin/team-grid",
"title": "Team Grid",
"category": "widgets",
"icon": "groups",
"description": "Responsive grid of team members.",
"supports": {
"html": false,
"align": ["wide", "full"]
},
"attributes": {
"members": {
"type": "array",
"default": []
},
"columns": {
"type": "number",
"default": 3
}
},
"textdomain": "team-grid",
"editorScript": "file:./build/index.js",
"editorStyle": "file:./build/index.css",
"style": "file:./build/style-index.css",
"render": "file:./render.php"
}
apiVersion: 3 is the latest. Older tutorials show apiVersion: 2 — don't follow them for new work; the editor iframe in v3 is a meaningful behaviour change.
Edit Component
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, RangeControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
export default function Edit({ attributes, setAttributes }) {
const { members, columns } = attributes;
const blockProps = useBlockProps({
className: `team-grid columns-${columns}`,
});
return (
<>
<InspectorControls>
<PanelBody title={__('Layout', 'team-grid')}>
<RangeControl
label={__('Columns', 'team-grid')}
value={columns}
min={1}
max={4}
onChange={(value) => setAttributes({ columns: value })}
/>
</PanelBody>
</InspectorControls>
<div {...blockProps}>
{members.map((member, i) => (
<div key={i} className="team-grid__item">
{member.name}
</div>
))}
</div>
</>
);
}
useBlockProps() is non-negotiable. It applies the editor's selected/hover/anchor classes correctly. Skipping it makes your block look broken when selected.
Static vs Dynamic — Pick One Up Front
Static blocks save HTML to post_content. The HTML is whatever your save.js returns. The benefit: fast frontend rendering, no PHP per request. The pain: changing the markup later means handling block deprecations.
Dynamic blocks save attributes to post_content as a comment. The frontend HTML is generated by render.php per request. The benefit: change the markup any time, no deprecations needed. The cost: PHP runs on every render.
For anything that depends on live data (recent posts, user state, dynamic data), use dynamic. For purely presentational components, static is simpler.
render.php for Dynamic Blocks
<?php
$members = $attributes['members'] ?? array();
$columns = $attributes['columns'] ?? 3;
?>
<div <?php echo get_block_wrapper_attributes( array( 'class' => "team-grid columns-{$columns}" ) ); ?>>
<?php foreach ( $members as $member ) : ?>
<div class="team-grid__item">
<?php echo esc_html( $member['name'] ); ?>
</div>
<?php endforeach; ?>
</div>
get_block_wrapper_attributes() matches useBlockProps() on the editor side — it applies any user-set alignment, custom CSS classes, and IDs.
Registering
add_action('init', function () {
register_block_type(__DIR__ . '/build');
});
That's it. register_block_type() reads block.json, registers everything, and wires the assets.
Testing
Block functional tests are awkward — the editor is a React app, full E2E tests are slow. What I do instead:
- Unit test the render.php logic — pull it into a function, test with PHPUnit/Pest
- Snapshot test the save.js output —
@wordpress/jest-preset-defaultincludes the matchers - Skip editor E2E unless paid — use Playwright if you must, but expect 30+ minutes of test runtime
Deprecations
The hardest part of static blocks: when you change the saved HTML, every existing post breaks until you write a deprecation. The deprecation tells the editor "this old structure is fine, here's how to migrate it":
const v1 = {
attributes: { /* old shape */ },
save: () => /* old HTML */,
migrate: (attrs) => ({ /* new shape */ }),
};
registerBlockType('alamin/team-grid', {
edit: Edit,
save: Save,
deprecated: [v1],
});
This is the #1 reason to use dynamic blocks for anything non-trivial.
Al Amin Ahamed
Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.
About me →More from the blog
← Older
Building Custom Commission Logic in Dokan Multi-Vendor
Newer →
Cutting Claude API Costs by 89% with Prompt Caching
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.
Check your inbox — confirmation link sent.