Creating Gutenberg blocks with ACF: a step-by-step guide (2026)
Creating custom blocks in Gutenberg has become standard practice for WordPress developers and site owners who want real control over how their content looks. You can build blocks from scratch with React, but there is a faster, more maintainable path: using Advanced Custom Fields (ACF) to generate custom blocks with PHP.
This guide is updated for 2026, ACF 6.x and the modern block.json workflow. You will learn the recommended way to register an ACF block, how to add fields, how to render the block, and, just as importantly, how to handle the editor preview and fix the issues that usually keep a block from working. Full code is included at every step.
Table of contents
- Why build Gutenberg blocks with ACF
- Two ways to register an ACF block
- Method 1: registering with block.json (recommended)
- Method 2: registering with PHP
- Adding the custom fields
- Rendering the block
- Handling the editor preview
- Enqueuing styles and scripts
- Nesting content with InnerBlocks
- Troubleshooting common issues
- FAQ
Why build Gutenberg blocks with ACF
Native block development means React, a build step, and boilerplate. ACF removes most of that. You define fields through a familiar interface, render the block with plain PHP, and skip the JavaScript toolchain entirely. The result is faster development, simpler data management, and blocks that are easy to hand over to a client or another developer.
The trade-off: an ACF block renders server-side, so its editor preview is a rendered snapshot rather than a live React component. That is rarely a problem, and this guide shows exactly how to handle it.
Two ways to register an ACF block
There are two supported approaches:
block.json(recommended since ACF 6.0 and the current WordPress standard). Metadata lives in a JSON file, and WordPress plus ACF wire everything together.- PHP registration with
acf_register_block_type(). Still fully supported and useful for dynamic registration.
One important note if you are updating older code: the original acf_register_block() function is deprecated. Use acf_register_block_type() or, better, block.json.
Method 1: registering with block.json (recommended)
Create a folder for your block, for example blocks/github-item/, containing a block.json file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
json{
"name": "acf/github-item",
"title": "GitHub Item",
"description": "A custom block that displays a GitHub repository.",
"category": "widgets",
"icon": "excerpt-view",
"keywords": ["github", "repository"],
"acf": {
"mode": "preview",
"renderTemplate": "render.php"
},
"style": "file:./style.css",
"supports": {
"align": true,
"anchor": true,
"jsx": true
}
}The acf key is what makes this an ACF block: mode controls the editor behaviour (more on that later) and renderTemplate points to the PHP file that renders the block, relative to block.json.
Then register the block by pointing WordPress to the folder that contains block.json:
1
2
3
add_action( 'init', function () {
register_block_type( __DIR__ . '/blocks/github-item' );
} );That is the entire registration. No acf_register_block_type() call is needed when you use block.json.
Method 2: registering with PHP
If you prefer to register in PHP (for example to register blocks dynamically), use acf_register_block_type() on the acf/init hook:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
add_action( 'acf/init', function () {
if ( ! function_exists( 'acf_register_block_type' ) ) {
return;
}
acf_register_block_type( array(
'name' => 'github-item',
'title' => __( 'GitHub Item' ),
'description' => __( 'A custom block that displays a GitHub repository.' ),
'render_template' => 'blocks/github-item/render.php',
'category' => 'widgets',
'icon' => 'excerpt-view',
'keywords' => array( 'github', 'repository' ),
'mode' => 'preview',
'supports' => array(
'align' => true,
'anchor' => true,
),
) );
} );Note the function name: acf_register_block_type(), not the deprecated acf_register_block().
Adding the custom fields
Whichever registration method you chose, the fields are defined the same way. You can create them in the ACF interface, but registering them in code keeps everything versioned:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
hpadd_action( 'acf/include_fields', function () {
if ( ! function_exists( 'acf_add_local_field_group' ) ) {
return;
}
acf_add_local_field_group( array(
'key' => 'group_github_item',
'title' => 'GitHub Item Block',
'fields' => array(
array(
'key' => 'field_github_repository_url',
'label' => 'Repository URL',
'name' => 'repository_url',
'type' => 'url',
),
),
'location' => array(
array(
array(
'param' => 'block',
'operator' => '==',
'value' => 'acf/github-item',
),
),
),
) );
} );The critical line is the location rule: value must be acf/ plus your block name (acf/github-item). If it does not match, the fields will not appear and nothing will save.
Rendering the block
The render template outputs the block’s HTML on both the frontend and the editor preview. Always escape output with esc_url(), esc_html() and esc_attr():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<?php
/**
* GitHub Item block template.
*
* @var array $block The block settings and attributes.
* @var bool $is_preview True while rendering the editor preview.
*/
$repository_url = get_field( 'repository_url' );
$anchor = ! empty( $block['anchor'] )
? $block['anchor']
: 'github-item-' . $block['id'];
$class_name = 'wp-github-repository';
if ( ! empty( $block['className'] ) ) {
$class_name .= ' ' . $block['className'];
}
?>
<div id="<?php echo esc_attr( $anchor ); ?>" class="<?php echo esc_attr( $class_name ); ?>">
<?php if ( $repository_url ) : ?>
<a href="<?php echo esc_url( $repository_url ); ?>" rel="noopener" target="_blank">
<?php echo esc_html( $repository_url ); ?>
</a>
<?php elseif ( $is_preview ) : ?>
<p class="acf-block-placeholder">Add a repository URL in the block settings.</p>
<?php endif; ?>
</div>Handling the editor preview
This is the part most tutorials skip, and it is where ACF blocks confuse people.
An ACF block does not render live in the editor the way a native React block does. Instead, ACF calls your PHP template on the server and injects the resulting HTML into the editor as a preview. The behaviour is controlled by the mode you set during registration:
preview: the editor shows the rendered block. Click it to reveal the fields.edit: the editor always shows the fields form.auto: the editor toggles between the rendered preview and the fields depending on selection.
Inside your render template you get a boolean, $is_preview, which is true when the block is being rendered for the editor preview and false on the frontend. Use it to show a helpful placeholder when the block is empty, as in the template above, so an unconfigured block still looks intentional in the editor instead of rendering blank.
Why does a block preview fail to render? Almost always one of these: a PHP error or notice inside the render template (the preview shows the error instead of your block), a get_field() call that assumes data that is not there yet, or output that is not escaped. Guard empty states with $is_preview, keep the template free of fatal errors, and the preview will render reliably.
Enqueuing styles and scripts
With block.json you do not enqueue assets manually. Declare them in the JSON and WordPress loads them only when the block is present:
1
2
3
4
5
{
"style": "file:./style.css",
"editorStyle": "file:./editor.css",
"viewScript": "file:./view.js"
}style: loaded on the frontend and in the editor preview.editorStyle: loaded only in the editor.viewScript: frontend-only JavaScript for interactive blocks.
If you register in PHP instead, pass an enqueue_assets callback to acf_register_block_type().
Nesting content with InnerBlocks
To let editors drop other blocks inside yours, enable "jsx": true in supports (already set in the block.json above) and add <InnerBlocks /> to your template:
1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$allowed_blocks = array( 'core/heading', 'core/paragraph' );
$template = array(
array( 'core/heading', array( 'level' => 3 ) ),
array( 'core/paragraph' ),
);
?>
<div class="my-container">
<InnerBlocks
allowedBlocks="<?php echo esc_attr( wp_json_encode( $allowed_blocks ) ); ?>"
template="<?php echo esc_attr( wp_json_encode( $template ) ); ?>"
/>
</div>Troubleshooting common issues
- The block does not appear in the inserter. Confirm ACF is active, that registration runs on the correct hook (
initforblock.json,acf/initfor PHP), and that thecategoryis valid. - Fields do not save or show. The location rule
valuemust exactly matchacf/your-block-name. - The editor preview is blank or shows an error. There is a PHP error in the render template. Check for notices, guard empty values with
$is_preview, and make sure every dynamic value is escaped. - Styles are missing in the editor. Add
editorStyleinblock.json; frontend-onlystylewill not appear in the preview. - InnerBlocks does nothing. You forgot
"jsx": trueinsupports.
Frequently asked questions
Is ACF better than native Gutenberg block development?
For most content blocks, yes: it is faster and needs no build step. For highly interactive blocks that require a live React interface in the editor, native development can be a better fit.
Does ACF support block.json?
Yes, since ACF 6.0. It is the recommended approach and aligns your blocks with the WordPress standard.
Is acf_register_block() deprecated?
Yes. Use acf_register_block_type(), or register through block.json.
Why is my ACF block preview not rendering in the editor?
Usually a PHP error in the render template, unescaped output, or code that assumes field data that is not set yet. Guard empty states with $is_preview and keep the template error-free.
Can ACF blocks contain other blocks?
Yes. Enable "jsx": true in supports and add to the template.
Conclusion
Building Gutenberg blocks with ACF gives you the control of custom blocks without the overhead of a JavaScript build pipeline. Register with block.json, define your fields, render with escaped PHP, and handle the editor preview with $is_preview. From here you can extend the same pattern to galleries, call-to-action sections, pricing tables, or any structured content your site needs.