To create custom Gutenberg blocks, generate the scaffold with @wordpress/create-block, describe the block in block.json (apiVersion 3) and write edit and save in React/JSX. The result is native, portable HTML stored in the database — with no plugin lock-in.
A custom Gutenberg block is your own component, registered via
block.json, that adds a content type to the WordPress editor and writes standard HTML to the database — with no third-party plugin required to render it.
Every “custom blocks” tutorial that opens with “install this blocks plugin” is solving the wrong problem. The way the pros do it — and what WordPress itself documents — is building a native block: a package with block.json, edit.js and save.js, compiled by the official toolchain. It is simpler than it looks, it does not require being a React wizard, and it delivers something no page builder does: your own clean HTML that survives a change of theme, plugin or agency. This guide builds one from scratch, with real code that runs.
What a native block is (and why it is not a blocks plugin)
When you create a native block, WordPress saves plain HTML to the database, wrapped in a block comment:
<!-- wp:pixelize/card -->
<div class="wp-block-pixelize-card"><p>Your content</p></div>
<!-- /wp:pixelize/card -->
That comment is the key. It is just a marker — the content between the tags is standard HTML. If the block stops existing tomorrow, WordPress still renders the HTML inside. Nothing breaks. It is the opposite of what happens with the popular alternatives:
- ACF Blocks and Lazy Blocks let you assemble blocks through an interface, but the final HTML is generated by a plugin PHP template on every request. Deactivate the plugin and the content evaporates.
- Elementor and other page builders store the structure as a serialized blob or a mountain of nested
<div>s with proprietary classes. The content is hostage to the product: without the plugin active, you see orphan shortcodes or a soup of unstyled divs.
The technical difference is lock-in. A native block writes HTML that belongs to WordPress, not to the plugin. That is why architecture matters: a native block is a light DOM, compatible with Full Site Editing (FSE) and theme.json, and portable across projects. This is the technical proof of why we recommend Gutenberg over page builders — the full argument is in Elementor vs Gutenberg.
A fair distinction: ACF Blocks solves legitimate cases (quick prototypes, teams without front-end skills). But when the goal is performance, portability and long-term maintenance, the native block is the gold standard — and that is what this guide teaches.
”Do I need to be a React expert?”
No. This is the biggest myth that stops developers at Gutenberg’s door.
You do not need to know Redux, advanced hooks, Suspense, Server Components or anything from the modern React ecosystem. What you need is much smaller:
- Understand JSX. JSX is HTML with superpowers —
<div className="card">{ text }</div>. If you read HTML, you read JSX. The two gotchas areclassNameinstead ofclassand curly braces{ }to insert JavaScript values. - Pass props. Your components receive
attributesandsetAttributes. You read from one, write with the other. That is it. - Use a handful of WordPress components.
useBlockProps,RichText,InspectorControls,TextControl. All documented, all with the same shape. You copy the pattern and adapt.
The toolchain does the boring work: @wordpress/create-block generates the whole project and @wordpress/scripts handles the build (Webpack, Babel, JSX → JavaScript). You never configure any of it. A developer who knows basic HTML, CSS and JavaScript ships their first working block in an afternoon. Let’s prove it.
Prerequisites and environment
You need Node.js and npm installed. The WordPress toolchain requires Node 20 or higher. Check the versions:
node -v
# v20.11.0 (20.x or higher)
npm -v
# 10.2.4
If the commands return valid versions, you are ready. If not, install Node from the official site or via a version manager like nvm. You will also need a running WordPress install (local, via wp-env, Local, Docker or staging) to install and test the generated plugin.
Step by step with @wordpress/create-block
From here it is hands-on. We will create a block called pixelize/card — a simple card with an editable title — and evolve it into a block with controls and a dynamic version.
1. Scaffold the project
@wordpress/create-block generates the whole project with one command. Run it inside your install’s wp-content/plugins folder (or anywhere, then move the build):
npx @wordpress/create-block pixelize-card --namespace pixelize
cd pixelize-card
--namespace pixelize sets the block prefix (pixelize/...), avoiding collisions with other authors’ blocks. The command downloads dependencies, creates the structure and leaves a working plugin ready to activate.
2. Generated file structure
When it finishes, you have something like this:
pixelize-card/
├── build/ # compiled output (generated by the build; do not edit)
├── src/
│ ├── block.json # block manifest
│ ├── index.js # registers the block (registerBlockType)
│ ├── edit.js # editor component
│ ├── save.js # what is written to the database
│ ├── style.scss # front-end + editor styles
│ ├── editor.scss # editor-only styles
│ └── view.js # JS that runs on the front-end
├── pixelize-card.php # plugin: register_block_type on init
├── package.json
└── readme.txt
You will almost always edit three files: block.json, edit.js and save.js. The toolchain manages the rest.
3. block.json — the block manifest
block.json is the heart of the block in apiVersion 3: the single source of metadata. It is what WordPress reads to register the block, enqueue scripts and styles on demand and stay compatible with FSE. Open src/block.json and adjust:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "pixelize/card",
"version": "0.1.0",
"title": "Pixelize Card",
"category": "design",
"icon": "id-alt",
"description": "A custom Pixelize card.",
"textdomain": "pixelize-card",
"supports": {
"html": false
},
"attributes": {
"content": {
"type": "string",
"source": "html",
"selector": "p"
}
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css",
"viewScript": "file:./view.js",
"render": "file:./render.php"
}
The fields that matter:
apiVersion: 3— the current block API version, with better isolation of the editor iframe.name— the uniquenamespace/slugidentifier. It is what appears in the<!-- wp:pixelize/card -->comment.attributes— the data the block stores. Here,contentis read from the HTML of the<p>tag written by save.editorScript/style/viewScript— WordPress loads each asset only when the block is in use.editorScriptruns in the editor,styleapplies to editor and front-end,viewScriptruns on the front-end only.render— the PHP that generates the HTML on the front-end (used in the dynamic version; see below).
4. Register the block
In JavaScript, src/index.js imports the metadata from block.json and wires up edit and save:
import { registerBlockType } from '@wordpress/blocks';
import './style.scss';
import Edit from './edit';
import save from './save';
import metadata from './block.json';
registerBlockType( metadata.name, {
edit: Edit,
save,
} );
In PHP, pixelize-card.php registers the block on the init hook, pointing at the build folder (where the compiled block.json lives):
<?php
/**
* Plugin Name: Pixelize Card
* Description: Native custom Pixelize block.
* Version: 0.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function pixelize_card_block_init() {
register_block_type( __DIR__ . '/build/pixelize-card' );
}
add_action( 'init', 'pixelize_card_block_init' );
register_block_type takes the folder path, not a config array — it reads the block.json from there and does all the registration and enqueuing work.
5. edit.js — the editor experience
edit.js defines how the block looks and behaves inside the editor. Two protagonists: useBlockProps, which injects the classes and attributes WordPress expects on the root element, and RichText, an editable text field with formatting:
import { __ } from '@wordpress/i18n';
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function Edit( { attributes, setAttributes } ) {
const blockProps = useBlockProps();
return (
<RichText
{ ...blockProps }
tagName="p"
value={ attributes.content }
onChange={ ( content ) => setAttributes( { content } ) }
placeholder={ __( 'Write the card title…', 'pixelize-card' ) }
/>
);
}
value reads the content attribute; onChange writes it back with setAttributes on every keystroke. That is the entire Gutenberg data cycle in four lines.
6. save.js — what is written to the database
save.js defines the HTML that goes to the database. Here we use useBlockProps.save() (the version for saved HTML) and RichText.Content, which serializes the formatted text:
import { useBlockProps, RichText } from '@wordpress/block-editor';
export default function save( { attributes } ) {
const blockProps = useBlockProps.save();
return (
<RichText.Content
{ ...blockProps }
tagName="p"
value={ attributes.content }
/>
);
}
It is this HTML — <p class="wp-block-pixelize-card">…</p> — that gets saved between the block comments. Clean, semantic, portable. One apiVersion 3 detail: because block.json declares render, WordPress uses render.php to build the front-end and passes this save HTML as $content. For a fully static block, remove the render line from block.json and the HTML above is served directly, with no PHP. We will come back to this in the dynamic blocks section.
7. Build and test
With everything in place, @wordpress/scripts compiles. In development, use watch mode, which recompiles on every change:
npm start
Activate the Pixelize Card plugin in wp-admin, open the post editor and insert the “Pixelize Card” block. To generate the final optimized (minified) build for production:
npm run build
Done — your first native block is working. Now let’s make it professional.
Adding controls with InspectorControls
Every serious block has options in the sidebar: color, link, alignment. That is InspectorControls, the editor’s right-hand panel. You fill it with PanelBody (collapsible sections) and controls like TextControl.
First, declare the new attribute in block.json, inside attributes:
"attributes": {
"content": {
"type": "string",
"source": "html",
"selector": "p"
},
"buttonUrl": {
"type": "string",
"default": ""
}
}
Then update edit.js to render the panel and write to the attribute:
import { __ } from '@wordpress/i18n';
import {
useBlockProps,
RichText,
InspectorControls,
} from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';
export default function Edit( { attributes, setAttributes } ) {
const blockProps = useBlockProps();
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Card settings', 'pixelize-card' ) }>
<TextControl
label={ __( 'Button link', 'pixelize-card' ) }
value={ attributes.buttonUrl }
onChange={ ( buttonUrl ) => setAttributes( { buttonUrl } ) }
/>
</PanelBody>
</InspectorControls>
<div { ...blockProps }>
<RichText
tagName="p"
value={ attributes.content }
onChange={ ( content ) => setAttributes( { content } ) }
placeholder={ __( 'Write the card title…', 'pixelize-card' ) }
/>
</div>
</>
);
}
The pattern repeats for any control: value reads the attribute, onChange calls setAttributes. Swap TextControl for ColorPalette, ToggleControl or SelectControl and you have any option you can imagine. The <>…</> fragment wraps the panel and the content because InspectorControls is “teleported” to the sidebar — it does not appear inside the block.
Static vs dynamic block
This is the most important architectural decision when building a block. The rule:
Use static when the HTML is fixed at the moment of writing (card, highlight, CTA, quote):
save.jswrites the final HTML to the database and it is served as-is, with no PHP. Use dynamic when the content changes on each request (latest posts, price, counter, logged-in user data):savereturnsnulland arender.phpgenerates the HTML on the fly.
Static is faster (no PHP per request) and more robust. Dynamic is essential when the data does not exist at save time. Server-side rendering of blocks via render.php has been natively supported since WordPress 6.1.
Converting to dynamic
Two changes. First, save.js stops writing HTML and returns null — the content becomes PHP’s responsibility:
export default function save() {
return null;
}
Second, make sure block.json points at the render file (already included in our manifest):
{
"render": "file:./render.php"
}
Now create src/render.php. It receives three ready-made variables: $attributes (the block attributes), $content (the inner content, when present) and $block (the instance). Use get_block_wrapper_attributes() to generate the classes and attributes WordPress expects on the wrapper — the PHP equivalent of useBlockProps:
<?php
/**
* @var array $attributes Block attributes.
* @var string $content Inner block content.
* @var WP_Block $block Block instance.
*/
$card_content = $attributes['content'] ?? '';
$button_url = $attributes['buttonUrl'] ?? '';
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
<p><?php echo wp_kses_post( $card_content ); ?></p>
<?php if ( $button_url ) : ?>
<a class="wp-block-pixelize-card__button" href="<?php echo esc_url( $button_url ); ?>">
<?php esc_html_e( 'Learn more', 'pixelize-card' ); ?>
</a>
<?php endif; ?>
</div>
Note the care around security: wp_kses_post sanitizes the content HTML, esc_url validates the URL, esc_html_e escapes and translates the text. Every dynamic output goes through escaping — WordPress’s golden rule. get_block_wrapper_attributes() keeps the block compatible with theme.json supports (color, spacing, typography) without you rewriting anything.
Why this matters: portable HTML, zero lock-in and Core Web Vitals
Recapping what you built and why it beats a page builder on every axis that matters:
- Portable HTML. The block writes standard markup to the database, inside a
<!-- wp:pixelize/card -->comment. Changed theme? The HTML is still there. Deactivated the plugin? Static content stays renderable. There is no serialized blob or orphan shortcode. That is zero lock-in — the opposite of tying the site to a product. - Light DOM and Core Web Vitals. You control exactly the HTML that is generated — none of the nested
<div>layers page builders stack. Less DOM means less layout and paint work, which directly improves LCP and CLS. Add the conditional loading of assets (block.jsononly enqueues the CSS/JS when the block is on the page) and you get a fast base by design. - Compatible with FSE and theme.json. Because it is native, the block takes part in Full Site Editing and inherits the global styles from
theme.json— colors, spacing and typography defined once, applied everywhere.
That is why native blocks are the technical foundation of well-built WordPress sites — and the reason Pixelize prefers Gutenberg. If your project needs bespoke blocks, real performance and a site you (not a plugin) control, that is exactly the work we do in our WordPress development service. And if the goal is to avoid the pain along the way, it is worth knowing the common WordPress errors too.