Gutenberg block architecture patterns I use in production
Most Gutenberg tutorials start with a static block. That is the wrong default for most real use cases, and the rest of the bad patterns follow from that first mistake. Here is what actually works in production.
Why most tutorials teach bad patterns
The default block scaffold ships with a save() function that returns JSX. That is fine for a styled button. It is a maintenance disaster for anything that depends on external data. The problem is tutorials rarely explain why — they just show the pattern and move on. You learn the static block pattern before you understand when it does not apply.
The second common mistake is a monolithic index.js that bundles the entire block — editor UI, frontend behavior, and all imports — into one file. Every visitor to your site then loads editor dependencies they will never use.
Static vs dynamic blocks — a clear opinion
Use static blocks when the output is entirely determined by the block's own attributes at save time. Styled quotes, custom headings, call-to-action buttons. Static blocks are fast, portable, and their output travels with the post on export.
Use dynamic blocks when the output depends on anything outside the post: recent posts, taxonomy terms, user meta, third-party API responses. If that external data changes after the block is saved, every instance in the database throws a validation error on the next edit.
// block.json — opt into dynamic rendering with the render field
{
"apiVersion": 3,
"name": "myplugin/recent-posts",
"render": "file:./render.php"
}
With render set, WordPress calls render.php on every page load. Your save() function returns null. The block never stores HTML in post content — it stores only its attributes.
The useInnerBlocksProps pattern most tutorials skip
Every parent/child block pattern — slider and slides, tabs and panels, accordion and items — should use useInnerBlocksProps. Most tutorials still show the deprecated <InnerBlocks /> component, which does not give you control over the wrapper element.
import { useInnerBlocksProps, useBlockProps } from '@wordpress/block-editor';
const ALLOWED_BLOCKS = ['myplugin/slide'];
const TEMPLATE = [['myplugin/slide'], ['myplugin/slide']];
export function Edit() {
const blockProps = useBlockProps({ className: 'slider' });
const innerBlocksProps = useInnerBlocksProps(
{ className: 'slider__track' },
{
allowedBlocks: ALLOWED_BLOCKS,
template: TEMPLATE,
templateLock: false,
}
);
return (
<div {...blockProps}>
<div {...innerBlocksProps} />
</div>
);
}
The template prop pre-populates the block with child blocks on insert, giving editors a working starting point. allowedBlocks prevents them from dropping incompatible blocks inside. Without both, editors will break your expected structure within minutes of using the block.
viewScript vs editorScript — why splitting assets matters
block.json has two separate script fields for a reason:
editorScript— loaded only in the block editor. Put your React edit component here, along with anything that imports@wordpress/packages.viewScript— loaded on the frontend for site visitors. Keep this as small as possible. No React, no editor packages.
{
"name": "myplugin/slider",
"editorScript": "file:./index.js",
"viewScript": "file:./view.js",
"style": "file:./style-index.css",
"editorStyle": "file:./index.css"
}
The editorScript can import swiper/react, @wordpress/components, and any other editor dependency freely. The viewScript initialises vanilla Swiper from a data-swiper-config attribute. The two are entirely separate bundles. A full slider block can have a frontend payload under 15 KB while the editor component imports whatever it needs without penalty.
Most codebases I have reviewed bundle everything together because the scaffold does not split them by default. The performance cost is real and it is invisible during development.
The save() function trap
save() must be a pure function of the block's attributes. No exceptions.
// Wrong — produces different output on every render
export function save() {
return <p>Last updated: {new Date().toISOString()}</p>;
}
// Wrong — store value may change between saves
export function save() {
const siteTitle = wp.data.select('core').getSite()?.title;
return <p>{siteTitle}</p>;
}
// Right — output is determined entirely by attributes
export function save({ attributes }) {
return <p>{attributes.content}</p>;
}
The block validation system compares the serialized output of save() against what is stored in the database. If they differ — because time changed, because a store value changed, because an external fetch returned different data — the block breaks with a validation error and the editor prompts the user to recover or convert it. That error is your bug. The user did nothing wrong.
If you need runtime data in the rendered output, use a dynamic block with a PHP render_callback or the render field in block.json.
What makes a block production-ready
A block is production-ready when it survives a WordPress update, a content migration, and a developer who did not write it making an edit six months later. That means: the static/dynamic decision is made correctly, assets are split so the frontend is fast, save() is pure, and useInnerBlocksProps is used wherever child blocks exist.
These are not advanced patterns. They are the baseline. The scaffold does not enforce them — you have to choose them deliberately.