Code editor showing ACF block registration and PHP template for Gutenberg

ACF blocks are custom Gutenberg blocks built using the Block API in Advanced Custom Fields PRO. They let you register blocks in PHP, define their editable fields through ACF's field group interface, and render output with a PHP template — no React, no JavaScript bundler, no JSX. For WordPress developers who work in PHP and already know ACF, building custom blocks this way is significantly faster than learning the native JavaScript block development stack.

The Gutenberg block editor's native API expects blocks to be registered as JavaScript modules with React components for the editor UI. For development teams whose entire workflow is PHP-based, that is a large context switch. ACF's block API provides an abstraction layer: you define what the block contains using ACF fields, you write the output in PHP, and ACF handles all the JavaScript plumbing between your template and the editor. This guide covers the complete process from first registration to production-ready ACF blocks, including the newer block.json method introduced in ACF 6.

What You Need Before Starting

ACF blocks require Advanced Custom Fields PRO. The Block API is not available in the free version of ACF. The minimum version for acf_register_block_type() is ACF PRO 5.8. The block.json registration method requires ACF PRO 6.0 or later.

You also need WordPress 5.0 or later (Gutenberg is bundled from WordPress 5.0) and a theme or plugin to register your blocks in. Child themes work for this, though for any project with more than two or three custom blocks a dedicated functionality plugin or a custom plugin is a cleaner home.

ACF PRO does not need to be bundled inside your theme or plugin — it can be installed as a standalone plugin on the site. However, if you are distributing the theme or plugin to clients, you will need to handle the ACF PRO dependency separately.

How ACF Blocks Work

When you register an ACF block, ACF calls WordPress's register_block_type() under the hood. Your block is a real WordPress block type, not a custom ACF construct. The difference is in how the editor UI and the render output are handled.

In the block editor, when a content editor inserts your ACF block, the editor makes an AJAX request to WordPress to render the PHP template. The rendered output appears in the block canvas — this is the "preview" mode. The editor sidebar shows the ACF field UI, which is generated by ACF from your field group definitions. When the post is saved and viewed on the front end, the same PHP render template runs server-side and outputs the block HTML.

This server-side rendering model means your block output always reflects the current server state. Any change to the render template is immediately reflected site-wide for all instances of the block, without needing to re-save each post. That is a significant advantage over native static blocks that store their output as HTML in post content.

Registering an ACF Block with acf_register_block_type()

Register on the acf/init hook, which fires after ACF is fully loaded. Add this to your theme's functions.php or your plugin file:

add_action( 'acf/init', 'my_register_acf_blocks' );

function my_register_acf_blocks() {
    acf_register_block_type( array(
        'name'            => 'testimonial',
        'title'           => __( 'Testimonial', 'textdomain' ),
        'description'     => __( 'A custom testimonial block.', 'textdomain' ),
        'render_template' => get_template_directory() . '/template-parts/blocks/testimonial.php',
        'category'        => 'formatting',
        'icon'            => 'admin-comments',
        'keywords'        => array( 'testimonial', 'quote' ),
        'mode'            => 'preview',
        'supports'        => array(
            'align' => true,
        ),
    ) );
}

The key parameters to understand:

  • name: Machine-readable identifier. The registered block type becomes acf/testimonial in Gutenberg.
  • render_template: Absolute server path to the PHP template. Use get_template_directory() not get_template_directory_uri() — the URI version returns a URL, not a file path.
  • mode: 'preview' renders the PHP template in the editor canvas. 'edit' shows ACF fields directly. Preview is almost always the right choice.
  • icon: A Dashicon slug (e.g. 'admin-comments') or an inline SVG string.
  • category: Standard Gutenberg block categories: text, media, design, widgets, formatting, embed. You can register custom categories.

Setting Up the Field Group

In the WordPress admin under ACF → Field Groups, create a new field group. Add the fields your block template will use. For the testimonial example:

  • testimonial_quote — Textarea
  • testimonial_author — Text
  • testimonial_role — Text

In the Location Rules for this field group, set the condition to: Block → is equal to → Testimonial. The block name in the dropdown matches the title key you set in registration, not the name key. Save the field group.

The field group's location rule is what connects the fields to the block. Get this wrong and the fields will not appear in the block editor sidebar when the block is selected. If you see the block inserting correctly but no fields in the sidebar, the location rule is the first thing to check.

Writing the Block Template

Create the PHP template file at the path you specified in render_template. The full template for the testimonial block:

<?php
/**
 * Testimonial ACF Block Template
 * File: template-parts/blocks/testimonial.php
 */

// Fetch field values.
$quote  = get_field( 'testimonial_quote' );
$author = get_field( 'testimonial_author' );
$role   = get_field( 'testimonial_role' );

// Pass extra CSS classes and alignment from the block settings.
$class = isset( $block['className'] ) ? $block['className'] : '';
$align = isset( $block['align'] ) ? 'align' . $block['align'] : '';

// In preview mode inside the editor, return a static placeholder image
// so the editor stays fast and doesn't render half-saved data.
if ( isset( $block['data']['_is_preview'] ) && $block['data']['_is_preview'] ) {
    echo '<img src="' . esc_url( get_template_directory_uri() . '/img/block-previews/testimonial.png' ) . '"
               alt="Testimonial block preview" style="width:100%;height:auto;" />';
    return;
}
?>
<blockquote class="wp-block-testimonial <?php echo esc_attr( trim( $class . ' ' . $align ) ); ?>">
    <?php if ( $quote ) : ?>
        <p class="testimonial__quote"><?php echo esc_html( $quote ); ?></p>
    <?php endif; ?>

    <?php if ( $author ) : ?>
        <cite class="testimonial__author">
            <?php echo esc_html( $author ); ?>
            <?php if ( $role ) : ?>
                <span class="testimonial__role"><?php echo esc_html( $role ); ?></span>
            <?php endif; ?>
        </cite>
    <?php endif; ?>
</blockquote>

Three things to note:

  • get_field() retrieves values relative to the block context automatically — you do not need to pass a post ID. ACF handles the context switch internally.
  • The $block array is available in every ACF block template. It contains $block['id'], $block['className'] (from the "Additional CSS class" setting in the editor), $block['align'], and other block metadata.
  • Always escape output. Use esc_html() for displayed text, esc_url() for URLs, esc_attr() for HTML attribute values.

Preview Mode and the Editor Experience

With 'mode' => 'preview', clicking a block in the editor shows the rendered PHP output. Clicking the pencil icon switches to showing the ACF field sidebar. This gives editors a near-WYSIWYG experience without any extra JavaScript work on your end.

The _is_preview flag in $block['data'] is set to true when the template is rendering inside the editor preview. You can use this to return a lighter placeholder instead of the full template. This matters on pages with many ACF blocks — each block makes an AJAX render request when first inserted, and running complex queries or slow template code on every editor load slows the editing experience noticeably.

For simple blocks the full template is fine in preview mode. For blocks that run database queries, use taxonomy lookups, or make HTTP requests, serving a static placeholder image during preview and the real output on the front end is the better pattern.

The block.json Method (ACF 6.x)

ACF 6.0 added support for registering ACF blocks using a block.json file, which is how WordPress core recommends registering all block types. This approach is cleaner for projects with multiple blocks and integrates better with block development tooling.

Create a block.json file in a directory alongside your render template:

{
  "name": "acf/testimonial",
  "title": "Testimonial",
  "description": "A custom testimonial block built with ACF.",
  "category": "formatting",
  "icon": "admin-comments",
  "keywords": ["testimonial", "quote", "acf"],
  "acf": {
    "mode": "preview",
    "renderTemplate": "template-parts/blocks/testimonial.php"
  },
  "supports": {
    "align": ["wide", "full"],
    "color": {
      "background": true,
      "text": true
    }
  }
}

Then in your PHP registration, point ACF at the directory containing the block.json:

add_action( 'acf/init', 'my_register_acf_blocks' );

function my_register_acf_blocks() {
    // Point ACF at the directory that contains block.json.
    // ACF reads registration settings from the file automatically.
    if ( function_exists( 'acf_register_block_type' ) ) {
        acf_register_block_type(
            get_template_directory() . '/blocks/testimonial'
        );
    }
}

ACF detects the block.json in that directory and reads registration settings from it. The acf key in block.json contains ACF-specific settings like mode and renderTemplate. Standard block registration keys like name, title, category, supports, and keywords follow the core block registration schema.

The block.json method becomes the practical default for any new ACF block work on ACF 6.x projects. It keeps registration settings with the block files rather than scattered in PHP registration callbacks, and it makes block directories self-contained and portable.

Repeater Fields in ACF Blocks

Repeater fields work in ACF blocks using the same have_rows() / the_row() / get_sub_field() pattern used elsewhere in ACF. The block context is set automatically, so no post ID is needed:

<?php
/**
 * Team Members ACF Block — Repeater field example.
 * File: template-parts/blocks/team.php
 */
?>
<div class="wp-block-team-members">
    <?php if ( have_rows( 'team_members' ) ) : ?>
        <ul class="team-list">
        <?php while ( have_rows( 'team_members' ) ) : the_row(); ?>
            <?php
            $name  = get_sub_field( 'name' );
            $title = get_sub_field( 'job_title' );
            $photo = get_sub_field( 'photo' );
            ?>
            <li class="team-list__item">
                <?php if ( $photo ) : ?>
                    <img
                        src="<?php echo esc_url( $photo['url'] ); ?>"
                        alt="<?php echo esc_attr( $photo['alt'] ); ?>"
                        width="<?php echo esc_attr( $photo['width'] ); ?>"
                        height="<?php echo esc_attr( $photo['height'] ); ?>"
                    />
                <?php endif; ?>
                <h3><?php echo esc_html( $name ); ?></h3>
                <p><?php echo esc_html( $title ); ?></p>
            </li>
        <?php endwhile; ?>
        </ul>
    <?php else : ?>
        <p class="team-list__empty">No team members added yet.</p>
    <?php endif; ?>
</div>

Flexible Content fields work the same way. Group fields work with get_field() returning an array that you access with array key syntax. The full ACF field type range is available inside block templates — Image fields return the standard ACF image array, Link fields return the ACF link array with url, title, and target keys.

Enqueueing Block Styles and Scripts

Use the enqueue_style and enqueue_script keys in registration for straightforward cases, or the enqueue_assets callback when you need conditional logic or dependency declarations:

add_action( 'acf/init', 'my_register_acf_blocks' );

function my_register_acf_blocks() {
    acf_register_block_type( array(
        'name'            => 'testimonial',
        'title'           => __( 'Testimonial', 'textdomain' ),
        'render_template' => get_template_directory() . '/template-parts/blocks/testimonial.php',

        // Simple: single stylesheet loaded on front end and in editor.
        'enqueue_style'   => get_template_directory_uri() . '/assets/css/blocks/testimonial.css',

        // Use enqueue_assets callback when you need conditional logic or dependencies.
        // 'enqueue_assets' => 'my_testimonial_block_assets',
    ) );
}

// Callback used with enqueue_assets key above.
function my_testimonial_block_assets() {
    wp_enqueue_style(
        'block-testimonial',
        get_template_directory_uri() . '/assets/css/blocks/testimonial.css',
        array(),
        wp_get_theme()->get( 'Version' )
    );
}

Styles and scripts loaded via enqueue_style and enqueue_script are loaded on both the front end and inside the block editor. If you need editor-only or front-end-only loading, use the enqueue_assets callback with is_admin() checks or the dedicated block editor hooks.

For the block.json method, use the editorStyle, style, and script keys in the JSON file to reference registered asset handles, which aligns with core block asset registration conventions.

InnerBlocks: Letting Editors Add Content Inside ACF Blocks

ACF blocks support InnerBlocks — the Gutenberg mechanism that lets editors drop other blocks inside your block. Enable it by adding 'supports' => array( 'jsx' => true ) to your registration and placing <InnerBlocks /> in your template where you want editor-added content to appear:

<?php
/**
 * Card ACF Block with InnerBlocks support.
 * Allows editors to drop any Gutenberg blocks inside the card body.
 * Requires 'supports' => array( 'jsx' => true ) in registration.
 */

$title      = get_field( 'card_title' );
$class      = isset( $block['className'] ) ? $block['className'] : '';
$inner_data = isset( $block['innerBlocks'] ) ? $block['innerBlocks'] : array();
?>
<div class="wp-block-card <?php echo esc_attr( $class ); ?>">
    <?php if ( $title ) : ?>
        <h2 class="card__title"><?php echo esc_html( $title ); ?></h2>
    <?php endif; ?>
    <div class="card__body">
        <InnerBlocks />
    </div>
</div>

With jsx support enabled, ACF renders the template through a JSX parser that processes the <InnerBlocks /> tag into the actual Gutenberg InnerBlocks component. This is the only JSX syntax used in ACF block templates — everything else remains standard PHP. The allowedBlocks and template props on <InnerBlocks /> work as they do in native block development.

Troubleshooting ACF Blocks

Block not appearing in the block inserter. Verify that the acf/init action hook is firing and that ACF PRO is active. A PHP fatal error inside the registration callback silently prevents block registration. Check the PHP error log. Also confirm that the name key contains only lowercase alphanumeric characters and hyphens — no underscores, no spaces.

Fields not showing in the editor sidebar. The field group's location rule must be set to Block, not Post Type or any other rule. The block option in the dropdown is the block's title (e.g., "Testimonial"), not its name (e.g., "testimonial"). Mismatching these is the most common cause of missing fields.

Render template not found. Confirm the path in render_template is an absolute server path, not a URL. get_template_directory() gives you the correct server path. get_template_directory_uri() gives you a URL and will cause a "file not found" error.

get_field() returns null inside the block template. On first insertion, before any data is saved, all fields return null. Wrap every field output in existence checks (if ( $value ) :) to avoid outputting empty or broken markup. This is especially important for image fields, where trying to access $image['url'] on a null value causes a PHP warning.

Editor is slow with many ACF blocks on a page. Each block in preview mode makes an AJAX render call when the editor loads. For pages with ten or more ACF blocks, this adds up. Use the _is_preview flag to return a lightweight static placeholder in the editor context, reserving the full template for front-end rendering only.

Block.json not detected by ACF 6.x. The directory path passed to acf_register_block_type() must contain a block.json file directly — ACF does not recurse into subdirectories. Confirm the file is in the exact directory being passed, not in a subdirectory of it.

When to Use ACF Blocks

ACF blocks are the right choice when the development team is PHP-focused and wants to avoid maintaining a JavaScript build process for custom block UIs. They are also appropriate when the content editors are used to ACF's field interface and benefit from the familiar editing experience inside the block editor.

For blocks with complex interactive editor behavior — custom block controls, dynamic block previews that depend on JavaScript state, or blocks that integrate with the REST API for live data — native JavaScript block development gives you more direct access to the editor APIs. ACF blocks work best for content-rendering blocks where the structure is defined by fields and the output is generated by a PHP template.

Projects that already have ACF PRO installed for other purposes (custom post type meta, option pages, relationship fields) get ACF blocks at no additional complexity cost. The field group workflow is already in place, and block fields fit naturally into that existing structure.

For the broader WordPress editor workflow context, see the ClassicPress and Gutenberg decision notes. For a complete WordPress development workflow including coding standards and automated checks, the GitHub Actions for WordPress Coding Standards guide covers the CI setup that keeps block template code consistent across a team.