BLOCK QUALITY FOUNDATION · 3-PART SERIES

Part 1: Anatomy & First Block · Part 2: Design Contract · Part 3: Shipping & Audit

Most custom Gutenberg blocks ship broken. Not “doesn’t compile” broken. “Looks fine in the editor, falls apart inside a real theme, breaks the next time WordPress moves the editor into an iframe, and the customer’s site loses content during the next major upgrade” broken.

After running our portfolio audit across plugins from Kadence, Stackable, Spectra, and Otter, the pattern was obvious. The blocks that survive year over year share the same anatomy. The custom blocks we audit for clients usually do not. The gap is not talent. It is shared discipline.

This is the first of three posts on the block-quality foundation we use at Wbcom Designs. Part 1 covers the anatomy and the first five minutes of any new block. Part 2 walks the design contract: tokens, primitives, and the same-family rule. Part 3 covers how we ship and maintain blocks across 100+ products: deprecations, the audit pipeline, and the implementation checklist. Each part stands on its own. Read them in order if this is your first encounter with our methodology.

This is also the foundation the new WP AI Client in WordPress 7.0 amplifies. Every block that calls an AI provider through the new core API is one careless attribute away from leaking a key. Discipline at the block level is the only thing that scales.

The Five Files Every Production Block Ships

Open any block we ship. The file tree is identical:

my-block/
├── block.json          // manifest, apiVersion 3, all attributes declared
├── index.js            // registerBlockType entry point
├── edit.js             // editor UI, InspectorControls, token consumers
├── render.php          // dynamic rendering (the preferred default)
├── view.js             // viewScriptModule, Interactivity API store
├── style.scss          // frontend CSS, token-driven, no raw hex or px
├── editor.scss         // editor-only overrides, kept thin
├── deprecated.js       // entry for every breaking save() change
└── README.md           // one-page contract: attributes, hooks, examples

Predictable layout, identical across every block in every plugin in our portfolio. A new contributor opens any block and knows where to look in five seconds. The grep stays sane across a thousand blocks.

A few principles encoded in this structure:

No node_modules per block. The block inherits the plugin’s build pipeline. One Webpack config, one tsconfig, one ESLint, for the whole plugin. A block is a feature, not a project.

render.php is the default, not save.js. Dynamic blocks render server-side at every page view. They can read live data (recent posts, user state, theme settings). Static blocks store their output inside post_content, which means every markup change risks the dreaded “this block contains unexpected or invalid content” error. Dynamic blocks dodge that entire class of problem. Static still has its place for purely presentational blocks with no live data, but the default is dynamic.

README.md is not optional. Every block ships a one-page contract: what attributes it accepts, what hooks it exposes, what filters can modify its output, and a minimal usage example. Skipping the README is how a block becomes “the one Sumit wrote in 2024 that nobody understands anymore.” We do not have time for that.

From Zero to a Running Block in Seven Commands

The five-file anatomy is the destination. The path there should be reproducible by anyone on the team, on any laptop, in under five minutes. Here is the sequence we run:

# 1. Verify Node version (20 LTS or newer)
node -v

# 2. Scaffold a dynamic block
npx @wordpress/create-block@latest my-block --variant=dynamic

# 3. Start the dev server (watches src/, rebuilds on save)
cd my-block
npm start

# 4. Symlink into a local WordPress install
ln -s $PWD/my-block /path/to/wp-content/plugins/my-block

# 5. Activate (WP Admin > Plugins > My Block > Activate)

# 6. Insert in a post (Posts > Add New > / > "My Block")

# 7. Production build before shipping
npm run build

Seven commands. The first one is a sanity check. The last one is a sanity check. The five in between are the actual work. The whole sequence runs in three to five minutes the first time, ninety seconds once you have done it twice.

If you are converting an existing block from apiVersion 2 to 3 (which you should be, since WordPress 7.0 iframes the post editor and breaks apiVersion 2 blocks in ways that are hard to debug), the sequence is the same except you start from your existing folder. Replace step 2 with reading your existing block.json and updating the apiVersion field. The mechanics of the migration are covered in Part 3, where the deprecations section earns its keep.

Attributes Are State. Controls Change State.

The two concepts every block author has to understand on day one. Get this wrong and nothing else works.

An attribute is a piece of data that belongs to a block instance. Color. Alignment. Padding. A toggle. A piece of content. Every author-visible setting on a block is an attribute. Attributes are declared in block.json:

{
  "apiVersion": 3,
  "name": "wbe/card",
  "attributes": {
    "alignment": { "type": "string", "default": "left" },
    "padding":   { "type": "object", "default": { "top": 16 } },
    "showIcon":  { "type": "boolean", "default": false }
  }
}

Three types cover roughly 80% of needs. string for selects, color values, plain text. object for grouped data (responsive spacing, hover states, nested config). boolean for toggles. The default value matters. An attribute without a default is undefined the first time the block is inserted, which leads to “why does my block render an empty div” debugging sessions that nobody wants.

A control is the editor UI that lets an author change an attribute. Controls live inside edit.js, registered through the InspectorControls component:

import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, SelectControl } from '@wordpress/components';

export default function Edit({ attributes, setAttributes }) {
  return (
    <>
      
        
           setAttributes({ alignment: v })}
          />
        
      
      
Content
); }

The flow is one-way: the control reads the attribute via attributes.X, the control writes back via setAttributes({ X: newValue }). WordPress handles persistence and undo/redo on its own.

The rule we enforce in code review: never read attributes outside useBlockProps() in apiVersion 3. The hook wraps your block’s root element with the right class names, alignment markup, and iframe-safe context. Skip it and your block looks fine in the editor on a fresh install and breaks the moment a theme adds a global stylesheet.

Why apiVersion 3 Is the Floor, Not a Choice

WordPress 7.0 shipped on May 20, 2026. The post editor now runs inside an iframe by default, the same way Site Editor has for two years. The visible consequence: blocks no longer share the wp-admin global namespace. No global window. No jQuery on the page. No theme CSS bleeding into the editor. No window.tinymce reach-through.

Blocks built at apiVersion 2 do not automatically work inside that iframe. The editor CSS loads, but the runtime context is different. Touch points that worked before because everything lived in the same document now fail silently. This is not a hypothetical: every plugin we audit on WP 7.0 has at least one apiVersion 2 block that looks correct, behaves correctly when isolated, and breaks the moment the author adds a second block to the same post.

apiVersion 3 was designed for this. The block runtime declares its dependencies (scripts, styles, modules) through block.json, and WordPress loads them inside the iframe correctly. The useBlockProps() hook returns the right props for the iframe context. The viewScriptModule field loads frontend behaviour as an ES module, which gives you proper scoping and module-level state.

The migration is mostly mechanical. The hard parts are:

  • Removing any direct `window.X` access in the editor side
  • Removing jQuery from `viewScript` and rewriting in vanilla or the Interactivity API
  • Updating any markup that depended on inherited admin CSS
  • Adding `useBlockProps()` to the root element if it was not already there

We have a full migration playbook for the fourteen common failure modes (covered in Part 3). For now, the takeaway is: if you ship custom blocks in 2026 and they are still on apiVersion 2, that is the highest-priority technical debt in your repository.

Static vs Dynamic: A Rule of Thumb

Two ways to render a block. Most teams pick the wrong one because the WordPress documentation does not push hard enough.

Static blocks use save.js. The function returns JSX that gets serialized to HTML and stored inside post_content. Every time the post loads, the stored HTML is rendered as-is. No PHP runs at render time for the block. Fast. Simple.

Dynamic blocks use render.php (or a render_callback in PHP). The block’s attributes are stored in post_content as a JSON comment, but the visible markup is generated by PHP at every page view.

The trade-off:

|, |, |, |

Render performanceFaster (HTML already in post)Slower (PHP runs every view)
Can read live dataNoYes (recent posts, user state, theme settings)
Markup migrationBreaks all existing instances unless you write a deprecated entryTrivial (just edit render.php)
Server-side filter hooksNoYes (`render_block` filters apply)

Our default is dynamic. The migration cost of static blocks is too high. The performance penalty of dynamic is real but usually irrelevant: a dynamic block adds maybe 1-3ms to a page render. You earn that back the first time you change the markup and avoid touching every existing post in the database.

Exceptions where static still wins: pure presentational blocks with no live data, no server-side filters, and zero chance the markup needs to change. A custom horizontal rule. A spacer. A static divider with three preset variants. For those, the simplicity is worth it.

For everything else: dynamic, always. Read the data live, ship the markup once, sleep at night.

How We Keep This Consistent Across 100+ Products

The five-file anatomy is enforced through our scaffold. The seven-command setup is documented in every plugin’s README. The attribute-and-control discipline is enforced by code review. The apiVersion 3 requirement is enforced by the wp-plugin-qa MCP that runs against every PR in approximately 121 milliseconds.

That last one matters. Discipline that depends on humans remembering rules during code review is a discipline that drifts. The MCP fixes that. The build fails when a rule slips. The PR cannot merge.

This is also where AI tooling has earned its keep at Wbcom. When Claude Code or Cursor generates a block for us, the audit catches anything they got wrong (and AI tooling does get block.json wrong frequently, especially the attribute schema). The output runs through the same gate as human work. We do not have a separate review process for AI-assisted code, because the rules are the rules, regardless of who wrote them.

What Comes Next

Part 2 unpacks the design contract:

  • The 4 architectural boundaries (REST contract, no jQuery, tokens never raw, same-family same-shell)
  • The 3-layer token model that lets every Wbcom plugin share visual vocabulary while keeping prefixed isolation
  • The 6 component primitives every block composes from (page, card, btn, input, badge, empty)
  • The 12 block-specific non-negotiables that map onto the broader 16-rule UI contract

Part 3 covers shipping and maintenance:

  • Deprecations done right (the migrate() function and why it is non-optional)
  • The 5 drift patterns we catch most in audits
  • ux-audit.sh as the local gate, the wp-plugin-qa MCP as the CI gate
  • The 22-item implementation checklist
  • Going deeper: Interactivity API stores, block context, transforms, apiVersion 2 to 3 migration playbook

If you are picking up Gutenberg block development for the first time, save this post and work through the seven commands on a fresh local WordPress. Get a block running before you move to Part 2. The mechanics matter less than the muscle memory.

Where this work happens at Wbcom Designs

We ship block-driven products across multiple plugin and theme families: BuddyPress extensions, Jetonomy community spaces, WP Vanguard, WPMediaVerse, and several private client systems. Every block in those products passes through this foundation. The methodology is what makes the portfolio coherent.

If you are an agency, plugin team, or in-house lead looking at adopting a similar foundation across your own work: this is the kind of engagement Wbcom takes on directly. Block-quality audits, custom integrations, and full-stack plugin engineering. The contact details are on wbcomdesigns.com.

Common Questions We Get When Teams First Adopt This Foundation

When agencies and in-house teams start adopting this foundation, the same questions surface in the first week. Capturing them here so the next team that picks up the thread does not have to re-ask.

Do we have to use dynamic blocks for everything? No. Static blocks have a real place. Pure presentational content with no live data, no theme overrides, no future markup churn: spacers, hard rules, branded dividers, decorative shapes. For everything that touches user data, post data, or theme settings, dynamic. The deprecation cost on static blocks is higher than the small PHP overhead on dynamic, so the default is dynamic and the exceptions earn their place.

What if our team is on PHP 7.4? Update to PHP 8.1 or later before adopting this foundation. WordPress 7.0 recommends PHP 8.3. The reason is not raw performance, though that is a nice bonus. The reason is that the audit tooling we ship assumes 8.x syntax in the PHP parser. PHP 7.x sites cannot run wp-plugin-qa MCP cleanly. Hosting migrations are the highest-value technical work most agencies are still avoiding in 2026.

Can we mix this foundation with classic shortcodes during a migration? Yes, but be explicit about the migration window. We have plugins that exposed both a block and a classic shortcode for two release cycles, then deprecated the shortcode in a third. The shortcode rendered the same render.php output as the block, so the visual layer was already on the foundation. The deprecation notice went into the admin panel during cycle two. Customers got two cycles of warning before the shortcode disappeared. Smooth migrations look like this. Hard cutovers do not.

How do we handle a third-party plugin that breaks the foundation in our codebase? Audit it. Decide whether to wrap it or replace it. We have written compatibility shims for plugins that violate too many of our rules but were too embedded in client sites to remove. The shim normalizes the violations through a Wbcom-compliant interface. Hides their UI behind our card primitive. Reroutes their CSS through our token layer. Most third-party plugins get wrapped, not replaced. The wrap is faster, the foundation stays clean.

What about Elementor and other page builders? Out of scope for this series. The foundation is for Gutenberg block development. If a client’s site is on Elementor or another non-Gutenberg builder, we either propose a migration to Gutenberg as part of the engagement, or we ship our work as Elementor widgets that follow as much of the foundation as the Elementor API allows. The choice depends on the client’s commitment to long-term WordPress evolution.

Does this work for multisite networks? Yes, with one extra rule we did not cover above: every block that stores state must namespace its option keys per subsite. We use a {prefix}_{subsite_id}_{option_name} pattern so a block on one subsite cannot read another subsite’s settings. The audit catches global option_name reads that should be scoped.

How long does it take a junior developer to ship their first foundation-compliant block? First block takes 2 to 4 days, mostly spent learning the tooling. Second block takes 1 day. By the fifth block they are at agency pace, which is half a day per block. The foundation feels heavy at first because it is more rules than a junior developer is used to. After roughly five blocks the rules disappear into muscle memory and the developer realizes the foundation is actually faster to ship than the unconstrained alternative, because there are fewer decisions to make per file.

What if we hate the prefix system and want a shared wbcom namespace across plugins? We tried that in 2022. Two plugins evolved at different paces, the shared namespace started disagreeing with itself, and we spent a quarter rolling back to per-plugin prefixes. The same-family-same-shell rule does not require a shared namespace. It requires the same shape across different namespaces. That distinction matters. Different prefixes preserve plugin independence. The shape across prefixes preserves portfolio coherence. The audit enforces the shape.

A Note on the Series

This is Part 1 of three. The mechanics are here. The design contract is in Part 2 (tokens, primitives, the same-family rule, the four architectural boundaries). The shipping and maintenance pipeline is in Part 3 (deprecations, audits, the wp-plugin-qa MCP, the 22-item implementation checklist). Each part stands alone but the value compounds when you read all three. Save the links. Work through them at your own pace.

If you have a question this post did not answer, ping me on X at @vapvarun. I read everything and reply where I can. The questions in this section came from real readers. If you have a better one, I want to hear it.

The talk this series accompanies was delivered at the WordPress Meetup in Lucknow on May 23, 2026. The slides, the printable checklist, and the live demo materials are linked from the closing slide. If your local WordPress meetup wants this session in person, get in touch through wbcomdesigns.com. We travel for meetups in India and we have done remote sessions for agencies in the US, Europe, and Australia. The foundation is what we share. The specific examples and the live build adapt to whoever is in the room.

If you scanned the QR at the meetup, this is the post your code matched. Cross-reference the take-home checklist against your next pull request, and ping me on X with your before-and-after audit scores.