In this ACF Gutenberg block tutorial, you will build a production-ready custom block using modern ACF Blocks v2 and native block.json declarations. Relying on legacy PHP registration functions generates unnecessary bloat and drops support for modern Block Editor features. By following this guide, you will set up clean block schemas, render lean semantic markup, integrate InnerBlocks, and enable crisp block previews.
Quick answer
- Store each block in its own directory containing
block.json,render.php, and optional scoped CSS files. - Register the block in your theme's
functions.phpusing the native WordPressregister_block_type()pointed at the block directory. - Define fields in the ACF admin interface or via PHP/JSON export, setting the location rule to match your registered block.
- Output fields inside
render.phpusing standardget_field()calls combined with strict escaping functions likeesc_html()orwp_kses_post(). - Add nested core blocks using
<InnerBlocks />with an explicittemplateandallowedBlockslist to prevent markup degradation.
Step 1: Scaffold the block with block.json
Legacy ACF block registration relied on acf_register_block_type() in a PHP hook. Modern block development requires block.json, the standard WordPress schema for blocks. This approach unlocks native script and style loading, meaning assets only load on pages where the block actually appears.
Create a directory in your theme or plugin named blocks/call-to-action/ and add a block.json file inside it:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "custom/call-to-action",
"title": "Call to Action",
"description": "A conversion-focused CTA banner with custom fields and nested blocks.",
"category": "design",
"icon": "megaphone",
"keywords": ["cta", "call to action", "banner"],
"acf": {
"mode": "preview",
"renderTemplate": "render.php"
},
"supports": {
"align": ["wide", "full"],
"anchor": true,
"html": false,
"jsx": true
},
"style": "file:./style.css",
"editorStyle": "file:./editor.css"
}
Setting "jsx": true inside supports is mandatory if you plan to use InnerBlocks inside your PHP template. Setting "mode": "preview" displays the block preview by default in the editor, letting users edit fields in the sidebar Inspector Controls or toggle into edit mode directly.
Step 2: Register the block in PHP
Do not use acf_register_block_type(). Instead, use the native WordPress function register_block_type() inside the init action hook. Point the function directly to the directory housing your block.json.
Add this snippet to your theme's functions.php or a dedicated block loader file:
add_action('init', 'theme_register_custom_blocks');
function theme_register_custom_blocks(): void {
// Ensure ACF PRO is active before registering
if (!function_exists('acf_register_block_type')) {
return;
}
$block_path = get_template_directory() . '/blocks/call-to-action';
if (file_exists($block_path . '/block.json')) {
register_block_type($block_path);
}
}
If you build multiple custom blocks, loop through the blocks/ directory using PHP's glob() function to register all block.json files automatically.
add_action('init', 'theme_register_all_blocks');
function theme_register_all_blocks(): void {
if (!function_exists('acf_register_block_type')) {
return;
}
$block_dirs = glob(get_template_directory() . '/blocks/*', GLOB_ONLYDIR);
foreach ($block_dirs as $dir) {
if (file_exists($dir . '/block.json')) {
register_block_type($dir);
}
}
}
Step 3: Configure ACF field groups
Once the block is registered, open your WordPress admin and navigate to Custom Fields > Field Groups. When you create a new field group, the Location Rules dropdown will now include your block under the Block option.
Set the condition to Block is equal to Call to Action (custom/call-to-action).
Add two fields to this group: - background_tone (Select: "light", "dark", "accent") - tracking_id (Text: optional analytics tracking identifier)
Avoid creating fields for basic content like headings, body paragraphs, or buttons if native WordPress blocks handle them better. Use ACF fields for structured metadata, style variations, and operational inputs, while leaving layout text to native blocks.
Step 4: Write the lean render template with InnerBlocks
Create render.php inside the block directory. ACF automatically passes several variables into this scope: $block, $content, $is_preview, $post_id, and $wp_block.
Here is how to extract and render attributes cleanly without unnecessary wrappers:
<?php
/**
* Block Name: Call to Action
*
* @param array $block The block settings and attributes.
* @param string $content The block inner HTML (empty).
* @param bool $is_preview True during backend preview render.
* @param int $post_id The post ID this block is saved to.
*/
// Build unique wrapper ID
$block_id = 'cta-' . $block['id'];
if (!empty($block['anchor'])) {
$block_id = $block['anchor'];
}
// Build CSS classes
$class_names = ['custom-cta-block'];
if (!empty($block['className'])) {
$class_names[] = $block['className'];
}
if (!empty($block['align'])) {
$class_names[] = 'align' . $block['align'];
}
$tone = get_field('background_tone') ?: 'light';
$class_names[] = 'tone-' . sanitize_html_class($tone);
// Define InnerBlocks layout template and allowed types
$allowed_blocks = ['core/heading', 'core/paragraph', 'core/buttons'];
$template = [
['core/heading', ['level' => 2, 'placeholder' => 'Add your CTA heading...']],
['core/paragraph', ['placeholder' => 'Add a short explanation or offer details...']],
['core/buttons', [], [
['core/button', ['text' => 'Get Started']],
]],
];
?>
<section id="<?php echo esc_attr($block_id); ?>" class="<?php echo esc_attr(implode(' ', $class_names)); ?>">
<div class="custom-cta-block__container">
<InnerBlocks
allowedBlocks="<?php echo esc_attr(wp_json_encode($allowed_blocks)); ?>"
template="<?php echo esc_attr(wp_json_encode($template)); ?>"
/>
</div>
</section>
Always defineallowedBlockson your<InnerBlocks />wrapper. Omitting it lets content creators insert full-width sliders, tables, or embeds directly inside your constrained UI components, breaking your site layout.
Step 5: Implement editor previews and styles
When editors view the block library list, WordPress displays an interactive or graphical preview. Without configuration, an ACF block preview renders an empty box.
Adding an example preview in block.json
Add an example object inside block.json. You can pass preview attributes or custom ACF data directly to make the preview render accurately in the block inserter drawer:
{
"example": {
"attributes": {
"mode": "preview",
"data": {
"background_tone": "accent"
}
}
}
}
Scoping the CSS
Create style.css in the block folder for assets required on both the front end and backend editor:
.custom-cta-block {
padding: 3rem 1.5rem;
border-radius: 8px;
margin-top: 2rem;
margin-bottom: 2rem;
}
.custom-cta-block.tone-light {
background-color: #f3f4f6;
color: #111827;
}
.custom-cta-block.tone-dark {
background-color: #111827;
color: #f9fafb;
}
.custom-cta-block.tone-accent {
background-color: #2563eb;
color: #ffffff;
}
.custom-cta-block__container {
max-width: 768px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
Because WordPress maps style in block.json to the front end and the block editor canvas automatically, your styles stay synchronized between environments without extra enqueue_block_assets hooks.
Troubleshooting common ACF block issues
The block does not appear in the block inserter
Verify your block.json syntax with a JSON validator. A single trailing comma prevents WordPress from registering the block. Check that the block category matches a registered category (e.g., text, media, design, widgets, theme, or a custom category registered via block_categories_all).
InnerBlocks renders as literal XML text
If <InnerBlocks /> outputs raw HTML on the front end instead of parsed block markup, check block.json. You must declare "jsx": true inside the supports object. Without this flag, ACF treats the template as standard PHP output without passing the JSX nodes to the WordPress block parser.
Field values do not update during live editing
If editing fields in the sidebar fails to trigger a re-render in the editor, check your render template for cached variables. In render.php, avoid storing get_field() output in global or persistent scopes. ACF refreshes the template by fetching the latest POST payload during Gutenberg preview reloads.
Checklist
- Dedicated folder created inside your theme or plugin for each custom block.
- Valid
block.jsonfile created usingapiVersion: 3and schema definition. supports.jsxset totrueinsideblock.jsonif usingInnerBlocks.- Block registered through
register_block_type()on theinitaction hook. - ACF Field Group location targeted specifically to the registered block name.
- Template attributes (
class,id) and ACF field outputs escaped usingesc_attr()orwp_kses_post(). allowedBlocksand defaulttemplatedefined on<InnerBlocks />to protect design consistency.- Scoped styles linked directly through
file:./style.cssinblock.json.
Frequently asked questions
Why should I use block.json instead of acf_register_block_type?
Using block.json follows core WordPress standards and enables asset optimization. WordPress only loads styles and scripts linked in block.json on pages where the block exists. The legacy PHP function acf_register_block_type is deprecated in modern workflows and lacks automatic asset dependency resolution.
How do I pass data to the Gutenberg preview for an ACF block?
Define an example object inside your block.json file. Inside the example attributes, add a data object containing key-value pairs matching your ACF field names. The WordPress inserter uses these values to generate a populated visual preview when users hover over your block.
Can I nest other blocks inside an ACF custom block?
Yes. Add supports: { jsx: true } to your block.json, then place an <InnerBlocks /> tag in your render.php template. You can restrict which blocks editors can insert by passing an array of block names to the allowedBlocks attribute.
- Block Editor HandbookBlock Editor Handbook
- ACF documentationACF documentation
Drafted with AI assistance. Code targets current WordPress, WooCommerce and Next.js APIs; test changes on a staging site before production.