gutenberg-block-development
Best practices for creating custom WordPress Gutenberg blocks with React/TypeScript, covering scaffolding, block architecture, attributes, dynamic vs static rendering, and testing
WordPress Gutenberg Custom Block Development
This skill provides guidance for creating custom Gutenberg blocks for WordPress using modern JavaScript/TypeScript and React.
Prerequisites & Environment Setup
Required Knowledge:
- WordPress plugin development fundamentals
- JavaScript ES6+ / TypeScript
- React basics (components, hooks, JSX)
- Understanding of WordPress block editor concepts
Development Environment:
- Node.js and npm installed
- WordPress development environment (Local, wp-env, or Docker)
- Code editor with TypeScript/React support
Essential Packages:
@wordpress/create-block- Official scaffolding tool@wordpress/scripts- Build tooling (webpack, babel, etc.)@wordpress/components- UI component librarywp-env(optional) - Local WordPress environment via Docker
Quick Start: Scaffolding a Block
Using @wordpress/create-block
Basic scaffolding (static block):
cd /path/to/wordpress/wp-content/plugins
npx @wordpress/create-block@latest my-custom-block --namespace=my-namespace
cd my-custom-block
npm start
Dynamic block with standard template:
npx @wordpress/create-block@latest my-dynamic-block --namespace=my-namespace --variant dynamic
Interactive template (always dynamic, uses Interactivity API):
# JavaScript variant (default)
npx @wordpress/create-block@latest my-interactive-block --template @wordpress/create-block-interactive-template
# TypeScript variant
npx @wordpress/create-block@latest my-interactive-block --template @wordpress/create-block-interactive-template --variant typescript
Note: Interactive template blocks are always dynamic (use render.php) and include the WordPress Interactivity API for reactive frontend experiences.
Key flags:
--namespace- Your unique namespace (required to avoid conflicts)--no-plugin- Scaffold block files only (no plugin wrapper)--wp-env- Add wp-env configuration for local development--category- Set block category (text, media, design, widgets, theme, embed)
Generated Structure
my-custom-block/
├── build/ # Compiled production code (don't edit)
├── node_modules/ # Dependencies (don't commit)
├── src/ # Your development files
│ ├── block.json # Block metadata (THE BRAIN)
│ ├── edit.js # Editor component
│ ├── save.js # Frontend output (static blocks)
│ ├── style.scss # Frontend styles
│ └── editor.scss # Editor-only styles
├── package.json
└── my-custom-block.php # Plugin entry point
Development Workflow
After scaffolding a block:
1. Get the plugin into your WordPress environment:
- Copy/move to your WordPress plugins folder, or
- Use
wp-envfor a containerized WordPress environment (see below)
2. Start development or build for production:
# Development (watches for changes, rebuilds automatically)
npm start
# Production (optimized, minified build)
npm run build
Using wp-env:
# 1. Start build process
npm start # or npm run build for production
# 2. Launch WordPress environment
npx wp-env start
# Access at http://localhost:8888
# Admin: http://localhost:8888/wp-admin (admin/password)
Note: For development, run npm start in one terminal, then wp-env start in another (npm start runs continuously).
Skill Scope: Single Block Per Plugin
This skill focuses on the standard scaffolding pattern: one block per plugin. The structure is:
my-custom-block/
├── src/
│ ├── block.json # Block metadata at src root
│ ├── edit.js
│ ├── save.js
│ ├── style.scss
│ └── editor.scss
├── build/ # Compiled output
└── my-custom-block.php # Plugin entry
Multiple blocks per plugin requires advanced build configuration and is outside this skill's scope. For theme-based blocks or multiple blocks, consult the Block Development Examples repository.
Post-Scaffolding: Customize Default Styles
The scaffolded style.scss contains placeholder styles meant to be customized:
// src/style.scss - SCAFFOLDED PLACEHOLDER
.wp-block-my-namespace-my-block {
background-color: #21759b; // Placeholder - customize or remove
color: #fff; // Placeholder - customize or remove
padding: 2px;
}
Why customize:
style.scssapplies to BOTH editor AND frontend- Placeholder styles may not match your design
- For reusable blocks, keep minimal to respect user themes
Recommended approach:
// src/style.scss - CUSTOMIZED
/**
* Shared styles (editor and frontend).
* Keep minimal for maximum theme compatibility.
*/
.wp-block-my-namespace-my-block {
// Add only essential structural styles
// Let themes control colors, typography, spacing
}
Note: editor.scss is editor-only and can have more specific styling for the editing experience.
Core Concepts
1. block.json - The Block's Brain
The block.json file is the single source of truth for block metadata:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "my-namespace/my-block",
"title": "My Custom Block",
"category": "widgets",
"icon": "smiley",
"description": "A custom block example",
"keywords": ["custom", "example"],
"version": "1.0.0",
"textdomain": "my-custom-block",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"attributes": {
"content": {
"type": "string",
"source": "html",
"selector": "p"
}
},
"supports": {
"html": false,
"anchor": true,
"align": true
}
}
Critical fields:
apiVersion- Use 3 for latest featuresname- Must be unique:namespace/block-nameicon- Visual identifier (see Icon Selection below)attributes- Define data structure and storagesupports- Enable/disable core features
Icon Selection
Icons appear in the block inserter and help users identify your block quickly.
Built-in Dashicons (easiest):
"icon": "smiley"
Common choices:
- Text/content:
"text","editor-paragraph","editor-alignleft" - Media:
"format-image","format-video","format-gallery" - Layout:
"layout","columns","grid-view" - Interactive:
"button","forms","testimonial" - Data:
"chart-bar","analytics","database" - Social:
"share","email","twitter"
Full list: https://developer.wordpress.org/resource/dashicons/
Custom SVG icon:
"icon": {
"src": "<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><path d='M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z' fill='currentColor'/></svg>"
}
SVG with custom colors:
"icon": {
"background": "#7e70af",
"foreground": "#fff",
"src": "<svg>...</svg>"
}
Best practices:
- Keep SVG viewBox at
0 0 24 24for consistency - Use
currentColorfor fill/stroke to respect theme colors - Avoid complex SVGs (keep paths simple)
- Test icon visibility in both light and dark editor themes
- Choose icons that visually represent the block's purpose
- For brand blocks, use brand colors in background/foreground
2. Attributes - Data Management
Attributes control how blocks store and retrieve data:
"attributes": {
"title": {
"type": "string",
"source": "html",
"selector": "h2",
"default": "Default Title"
},
"isActive": {
"type": "boolean",
"default": false
},
"items": {
"type": "array",
"default": []
},
"settings": {
"type": "object",
"default": {}
}
}
Type options: string, boolean, number, integer, array, object, null
Source options:
attribute- Extract from HTML attributetext- Extract text contenthtml- Extract inner HTMLquery- Extract multiple elementsmeta- Store in post meta
3. Static vs Dynamic Blocks
Scaffolding Choice:
- Static block:
npx @wordpress/create-block my-block - Dynamic block:
npx @wordpress/create-block my-block --variant dynamic
Using --variant dynamic sets up the correct structure from the start.
Static Blocks:
- HTML saved to database at save time
- Content persists even if plugin deactivated
- Requires manual updates (re-save post)
- Best for: Content that rarely changes, distributed plugins
// edit.js
export default function Edit({ attributes, setAttributes }) {
return (
<div {...useBlockProps()}>
<RichText
tagName="p"
value={attributes.content}
onChange={(content) => setAttributes({ content })}
/>
</div>
);
}
// save.js
export default function Save({ attributes }) {
return (
<div {...useBlockProps.save()}>
<RichText.Content tagName="p" value={attributes.content} />
</div>
);
}
Dynamic Blocks:
- Rendered via PHP at runtime
- Always current (updates automatically)
- More database queries
- Best for: Theme-specific blocks, data that changes, client projects
Scaffolded with --variant dynamic:
// render.php (automatically created)
<?php
/**
* Available variables:
* @var array $attributes The block attributes
* @var string $content The block default content
* @var WP_Block $block The block instance
*/
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
<?php echo esc_html( $attributes['content'] ?? 'Default content' ); ?>
</div>
// index.js - No save function needed
registerBlockType( metadata.name, {
edit: Edit, // Only edit function
} );
block.json configuration:
{
"render": "file:./render.php",
"viewScript": "file:./view.js" // Optional frontend JS
}
Converting static to dynamic: If you scaffolded a static block and need to convert it:
- Create
render.phpin the block directory - Add
"render": "file:./render.php"toblock.json - Remove the save function from
index.js - Rebuild with
npm run build
Decision Guide:
- Theme-specific design/layout → Dynamic (theme-coupled)
- Functionality/features → Static (plugin)
- Real-time data (posts, users, API) → Dynamic
- User-generated content → Static
- Open source distribution → Static
4. Block Controls
Toolbar Controls (quick access):
import { BlockControls } from '@wordpress/block-editor';
import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
<BlockControls>
<ToolbarGroup>
<ToolbarButton
icon="admin-links"
label="Add Link"
onClick={() => {/* handle */}}
/>
</ToolbarGroup>
</BlockControls>
Inspector Panel (detailed settings):
import { InspectorControls } from '@wordpress/block-editor';
import { PanelBody, ToggleControl, TextControl } from '@wordpress/components';
<InspectorControls>
<PanelBody title="Settings" initialOpen={true}>
<ToggleControl
label="Enable Feature"
checked={attributes.isActive}
onChange={(isActive) => setAttributes({ isActive })}
/>
<TextControl
label="Custom Text"
value={attributes.customText}
onChange={(customText) => setAttributes({ customText })}
/>
</PanelBody>
</InspectorControls>
TypeScript Integration
Setting Up TypeScript (Manual)
1. Install dependencies:
npm install --save-dev typescript @types/react @types/wordpress__block-editor @types/wordpress__blocks @types/wordpress__components
2. Create tsconfig.json:
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["dom", "esnext"],
"jsx": "react",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
3. Update webpack.config.js:
const defaultConfig = require('@wordpress/scripts/config/webpack.config');
module.exports = {
...defaultConfig,
resolve: {
...defaultConfig.resolve,
extensions: ['.tsx', '.ts', '.js', '.jsx']
},
module: {
...defaultConfig.module,
rules: [
...defaultConfig.module.rules,
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
}
]
}
};
4. Type your Edit component:
// edit.tsx
import { useBlockProps } from '@wordpress/block-editor';
interface EditProps {
attributes: {
content: string;
isActive: boolean;
};
setAttributes: (attrs: Partial<EditProps['attributes']>) => void;
className?: string;
}
export default function Edit({ attributes, setAttributes, className }: EditProps) {
const blockProps = useBlockProps();
return (
<div {...blockProps}>
{/* Your block UI */}
</div>
);
}
Common Patterns
Pattern: Custom Post Query Block
// Dynamic block that queries posts
import { useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
export default function Edit({ attributes }) {
const { postsPerPage = 5 } = attributes;
const posts = useSelect((select) => {
return select(coreStore).getEntityRecords('postType', 'post', {
per_page: postsPerPage,
_embed: true
});
}, [postsPerPage]);
if (!posts) return <Spinner />;
return (
<div {...useBlockProps()}>
{posts.map(post => (
<article key={post.id}>
<h3>{post.title.rendered}</h3>
</article>
))}
</div>
);
}
Pattern: Block with InnerBlocks (Static)
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
// edit.js
export default function Edit() {
const TEMPLATE = [
['core/heading', { level: 2, placeholder: 'Section Title' }],
['core/paragraph', { placeholder: 'Section content...' }]
];
return (
<div {...useBlockProps()}>
<InnerBlocks template={TEMPLATE} />
</div>
);
}
// save.js
export default function Save() {
return (
<div {...useBlockProps.save()}>
<InnerBlocks.Content />
</div>
);
}
Pattern: Dynamic Block with InnerBlocks
⚠️ Important Official Guidance: Per the Block Editor Handbook:
"For many dynamic blocks, the
savecallback function should be returned asnull... If you are using InnerBlocks in a dynamic block you will need to save the InnerBlocks in the save callback function using<InnerBlocks.Content/>"
This pattern is essential when you need to process or transform inner blocks on the server.
// edit.js
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
export default function Edit() {
const TEMPLATE = [
['core/group', {
className: 'slot-one',
metadata: { name: 'slot-one-content' }
}, [
['core/paragraph', { placeholder: 'First slot content...' }]
]],
['core/group', {
className: 'slot-two',
metadata: { name: 'slot-two-content' }
}, [
['core/paragraph', { placeholder: 'Second slot content...' }]
]]
];
return (
<div {...useBlockProps()}>
<InnerBlocks template={TEMPLATE} />
</div>
);
}
// save.js
// Must save InnerBlocks even though block is dynamic!
export default function save() {
return (
<div {...useBlockProps.save()}>
<InnerBlocks.Content />
</div>
);
}
PHP Render Callback:
The $block parameter provides access to inner blocks as WP_Block objects. Use the render() method to output them (Code Reference).
function my_block_render_callback( $attributes, $content, $block ) {
// Inner blocks are WP_Block objects
$inner_blocks = $block->inner_blocks;
$slot_one = '';
$slot_two = '';
// Render using ->render() method for WP_Block objects
if ( isset( $inner_blocks[0] ) ) {
$slot_one = $inner_blocks[0]->render();
}
if ( isset( $inner_blocks[1] ) ) {
$slot_two = $inner_blocks[1]->render();
}
// Transform inner content for custom output
return sprintf(
'<custom-element><div slot="one">%s</div><div slot="two">%s</div></custom-element>',
$slot_one,
$slot_two
);
}
register_block_type( __DIR__ . '/build', [
'render_callback' => 'my_block_render_callback'
] );
Key Methods (WordPress 6.0+):
WP_Block::render()- ForWP_Blockobjects from$block->inner_blocksrender_block($array)- For parsed block arrays fromparse_blocks()
Common Use Cases:
- Wrapping inner blocks with custom HTML (web components, special layouts)
- Adding server-side data or context to nested content
- Conditionally showing/hiding sections based on attributes
- Processing inner blocks based on user permissions or state
Pattern: Block Variations
// block.json
"variations": [
{
"name": "blue-variant",
"title": "Blue Style",
"icon": "admin-appearance",
"attributes": {
"backgroundColor": "blue"
}
},
{
"name": "red-variant",
"title": "Red Style",
"icon": "admin-appearance",
"attributes": {
"backgroundColor": "red"
}
}
]
Pattern: Frontend JavaScript with Blocks
Blocks can load frontend JavaScript using viewScript in block.json:
{
"viewScript": "file:./view.js"
}
Or reference multiple script handles (your own, core, or third-party):
{
"viewScript": ["file:./view.js", "wp-api-fetch", "my-external-library"]
}
External scripts are registered using standard WordPress wp_register_script() in your plugin's main PHP file.
Important: Define all scripts in block.json's viewScript array. Don't use view_script_handles in register_block_type() as it overrides the viewScript from block.json.
Note: Scripts only enqueue on pages where the block is used.
Testing & Quality Assurance
Manual Testing Checklist
When developing or modifying a block, verify:
Registration & Discovery:
- Block appears in inserter with correct icon/title
- Block appears in correct category
- Block can be searched by keywords
Functionality:
- Block can be added to editor without errors
- All toolbar controls work as expected
- Inspector panel controls update attributes correctly
- Attributes save and restore correctly
- Block renders correctly on frontend
- Dynamic blocks fetch and display current data
Compatibility:
- Works with block themes and classic themes
- Responsive across mobile/tablet/desktop
- No JavaScript console errors or warnings
- Works in posts, pages, and custom post types
- Works in widget areas (if applicable)
- Compatible with Full Site Editing (if applicable)
Accessibility:
- Keyboard navigation works throughout
- Screen reader announces elements correctly
- ARIA labels present where needed
- Focus indicators visible
- Color contrast meets WCAG standards
Performance:
- No unnecessary re-renders
- Scripts/styles only load when block present
- Images optimized and lazy-loaded
- No blocking JavaScript
Validation Testing
Block Deprecation: When changing save output, always test migration:
deprecated: [
{
attributes: { /* old structure */ },
save: OldSaveFunction,
migrate: (attributes) => {
// Transform old to new
return newAttributes;
}
}
]
Browser Testing:
- Chrome/Edge (Chromium)
- Firefox
- Safari (especially for CSS grid/flexbox)
WordPress Version Testing:
- Current stable release
- One version back (if supporting older sites)
- Beta/RC (if planning ahead)
Common Pitfalls & Solutions
Pitfall 1: Block Validation Errors
Problem: "This block contains unexpected or invalid content"
Cause: Save function output changed between versions
Solution:
- Use block deprecations when changing save output
- Never change existing attribute sources
- Test migration before deploying
Pitfall 2: Infinite Re-renders
Problem: Block constantly re-renders, editor freezes
Cause: Creating new objects/arrays in render
Solution:
// ❌ Bad - creates new array every render
const items = [];
// ✅ Good - memoize or use state
const [items, setItems] = useState([]);
Pitfall 3: Missing useBlockProps
Problem: Block wrapper styling doesn't work
Cause: Forgot useBlockProps() in edit or save
Solution:
// Always wrap your block
<div {...useBlockProps()}>
{/* content */}
</div>
Pitfall 4: RichText Content Loss
Problem: Content disappears on save
Cause: Missing RichText.Content in save function
Solution:
// edit.js
<RichText
tagName="p"
value={attributes.content}
onChange={(content) => setAttributes({ content })}
/>
// save.js
<RichText.Content tagName="p" value={attributes.content} />
Pitfall 5: Placeholder Styles Not Customized
Problem: Block has unexpected styling that clashes with themes
Cause: Scaffolded style.scss contains placeholder styles
Location: src/style.scss (applies to both editor and frontend)
Solution: Customize or remove the placeholder styles after scaffolding:
// Scaffolded placeholder
.wp-block-my-namespace-my-block {
background-color: #21759b; // Remove or customize
color: #fff; // Remove or customize
padding: 2px;
}
// Customized for production
.wp-block-my-namespace-my-block {
// Only essential structural styles
// Let themes control appearance
}
Remember:
style.scss→ Both frontend + editor (keep minimal)editor.scss→ Editor only (can be more specific)
Pitfall 6: InnerBlocks Content Lost in Dynamic Blocks
Problem: InnerBlocks content disappears after saving in a dynamic block
Cause: Returning null in save.js when using InnerBlocks
Official Guidance: Per the Block Editor Handbook: "If you are using InnerBlocks in a dynamic block you will need to save the InnerBlocks in the save callback function using <InnerBlocks.Content/>"
Solution:
// ❌ WRONG - InnerBlocks content won't persist
export default function save() {
return null;
}
// ✅ CORRECT - Save InnerBlocks even for dynamic blocks
export default function save() {
return (
<div {...useBlockProps.save()}>
<InnerBlocks.Content />
</div>
);
}
Pitfall 7: Fatal Error with Inner Block Rendering
Problem: Fatal error: Cannot use object of type WP_Block as array
Cause: Using wrong rendering method for WP_Block objects
Context: $block->inner_blocks returns an array of WP_Block objects, not arrays.
Solution:
// ❌ WRONG - Causes fatal error
$inner_blocks = $block->inner_blocks;
$output = render_block( $inner_blocks[0] ); // Fatal!
// ✅ CORRECT - Use ->render() method
$inner_blocks = $block->inner_blocks;
$output = $inner_blocks[0]->render();
Reference:
WP_Block::render()- ForWP_Blockobjectsrender_block()- For parsed block arrays
Performance Best Practices
- Minimize attribute updates - Batch
setAttributescalls - Lazy load dependencies - Import heavy libraries only when needed
- Optimize asset loading - Load scripts/styles only on pages using block
- Use block context - Share data between nested blocks efficiently
- Debounce user input - For search/filter controls
Internationalization (i18n)
Always wrap text strings:
import { __ } from '@wordpress/i18n';
const title = __('My Block Title', 'my-text-domain');
const label = _x('Settings', 'block settings label', 'my-text-domain');
const plural = _n('1 item', '%d items', count, 'my-text-domain');
Resources
Official Documentation:
- Block Editor Handbook: https://developer.wordpress.org/block-editor/
- Block Development Examples: https://github.com/WordPress/block-development-examples
- Block API Reference: https://developer.wordpress.org/block-editor/reference-guides/
- Gutenberg Storybook: https://wordpress.github.io/gutenberg/ (interactive component reference)
Learning Platforms:
- Learn WordPress: https://learn.wordpress.org/ (courses on block development)
- WordPress Developer Blog: Latest features and tutorials
Community:
- Make WordPress Slack: #core-editor channel
- GitHub Discussions: WordPress/gutenberg repository
Decision Trees
Should I Build a Custom Block?
Need custom functionality?
├─ No → Use core blocks or block patterns
└─ Yes → Is it reusable across sites?
├─ Yes → Build as plugin (static blocks)
└─ No → Is it theme-specific design?
├─ Yes → Build in theme (dynamic blocks)
└─ No → Consider if block is best solution
Static vs Dynamic?
Will content change frequently without editor intervention?
├─ Yes → Dynamic block
└─ No → Is this for wide distribution?
├─ Yes → Static block
└─ No → Are you comfortable with theme coupling?
├─ Yes → Dynamic block (simpler development)
└─ No → Static block (portable)
Version Notes
- WordPress 6.0+:
apiVersion: 3introduced - WordPress 5.8+: block.json is standard approach
- WordPress 5.5+: InnerBlocks improvements
- Always check compatibility requirements in plugin header
This skill should be used when:
- Creating new custom Gutenberg blocks
- Scaffolding block plugins
- Deciding between static/dynamic rendering
- Setting up TypeScript for block development
- Troubleshooting block validation errors
- Planning block architecture