Use when adding new error messages to React, or seeing "unknown error code" warnings.
npx skills add DaveRobinson/claude-skill--gutenberg-block
Or install specific skill: npx add-skill https://github.com/DaveRobinson/claude-skill--gutenberg-block
# Description
Best practices for creating custom WordPress Gutenberg blocks with React/TypeScript, covering scaffolding, block architecture, attributes, dynamic vs static rendering, and testing
# SKILL.md
name: gutenberg-block-development
description: Best practices for creating custom WordPress Gutenberg blocks with React/TypeScript, covering scaffolding, block architecture, attributes, dynamic vs static rendering, and testing
license: MIT
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 library
- wp-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-env for 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.scss applies 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 features
- name - Must be unique: namespace/block-name
- icon - Visual identifier (see Icon Selection below)
- attributes - Define data structure and storage
- supports - 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 24 for consistency
- Use currentColor for 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 attribute
- text - Extract text content
- html - Extract inner HTML
- query - Extract multiple elements
- meta - 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:
1. Create render.php in the block directory
2. Add "render": "file:./render.php" to block.json
3. Remove the save function from index.js
4. 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() - For WP_Block objects from $block->inner_blocks
- render_block($array) - For parsed block arrays from parse_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() - For WP_Block objects
- render_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
# README.md
WordPress Gutenberg Block Development Skill
Best practices for creating custom WordPress Gutenberg blocks following official patterns.
Scope
- Scaffolding blocks with
@wordpress/create-block - Static vs dynamic blocks
- Block.json configuration and attributes
- InnerBlocks patterns (static and dynamic)
- Frontend JavaScript with blocks
- Common pitfalls and solutions
Focuses on: Single block per plugin (standard WordPress pattern)
Out of scope: Multiple blocks per plugin, theme-based blocks
Installation
Claude Code (CLI)
Copy the skill directory to your skills folder:
cp -r gutenberg-block-creation ~/.claude/skills/
Claude.ai (Web)
- Go to Settings
- Navigate to Capabilities > Skills
- Use the upload option to add this skill
Usage
Activates automatically when working on Gutenberg blocks, or invoke directly:
@gutenberg-block-creation
License
MIT
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.