gutenberg-block-development — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited gutenberg-block-development (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
This skill provides guidance for creating custom Gutenberg blocks for WordPress using modern JavaScript/TypeScript and React.
Required Knowledge:
Development Environment:
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 DockerBasic 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 startDynamic block with standard template:
npx @wordpress/create-block@latest my-dynamic-block --namespace=my-namespace --variant dynamicInteractive 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 typescriptNote: 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)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 pointAfter scaffolding a block:
1. Get the plugin into your WordPress environment:
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 buildUsing 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).
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 entryMultiple 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.
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 frontendRecommended 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.
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 featuresIcons appear in the block inserter and help users identify your block quickly.
Built-in Dashicons (easiest):
"icon": "smiley"Common choices:
"text", "editor-paragraph", "editor-alignleft""format-image", "format-video", "format-gallery""layout", "columns", "grid-view""button", "forms", "testimonial""chart-bar", "analytics", "database""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:
0 0 24 24 for consistencycurrentColor for fill/stroke to respect theme colorsAttributes 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 metaScaffolding Choice:
npx @wordpress/create-block my-blocknpx @wordpress/create-block my-block --variant dynamicUsing --variant dynamic sets up the correct structure from the start.
Static Blocks:
// 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:
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:
render.php in the block directory"render": "file:./render.php" to block.jsonindex.jsnpm run buildDecision Guide:
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>1. Install dependencies:
npm install --save-dev typescript @types/react @types/wordpress__block-editor @types/wordpress__blocks @types/wordpress__components2. 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>
);
}// 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>
);
}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>
);
}⚠️ Important Official Guidance: Per the Block Editor Handbook:
"For many dynamic blocks, thesavecallback 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_blocksrender_block($array) - For parsed block arrays from parse_blocks()Common Use Cases:
// 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"
}
}
]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.
When developing or modifying a block, verify:
Registration & Discovery:
Functionality:
Compatibility:
Accessibility:
Performance:
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:
WordPress Version Testing:
Problem: "This block contains unexpected or invalid content"
Cause: Save function output changed between versions
Solution:
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([]);Problem: Block wrapper styling doesn't work
Cause: Forgot useBlockProps() in edit or save
Solution:
// Always wrap your block
<div {...useBlockProps()}>
{/* content */}
</div>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} />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)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>
);
}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 objectsrender_block() - For parsed block arrayssetAttributes callsAlways 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');Official Documentation:
Learning Platforms:
Community:
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 solutionWill 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)apiVersion: 3 introducedThis skill should be used when:
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.