DEV Community

Cover image for Build Custom Gutenberg Blocks Using ACF Blocks (No React Required)
WPVibes
WPVibes

Posted on Originally published at wpvibes.com

Build Custom Gutenberg Blocks Using ACF Blocks (No React Required)

For years, building custom Gutenberg blocks meant learning React, setting up a build pipeline, and wrestling with JavaScript tooling most PHP developers never signed up for. ACF Blocks changes that. If you already know PHP and use Advanced Custom Fields on your projects, you can build production-ready custom Gutenberg blocks today - with the same familiar get_field() calls, no React, no npm, no build step.

This guide walks through building a real plugin with three custom Gutenberg blocks using ACF Blocks, progressing from simple to advanced.

What You'll Build

Three production-ready blocks packaged as a single plugin (wpvibes-acf-blocks):

  • Testimonial Block: a customer quote with author name, title, and photo

  • Feature CTA Block: a feature card with image, heading, description, and button link

  • Team Members Block: a responsive grid of team members using ACF's Repeater field, with configurable column count

Each block lives in its own folder inside the plugin. Adding a new block later means creating one new folder - no changes to any existing file.

Prerequisites

  • WordPress 6.7 or later (any version supporting ACF Blocks V3)

  • ACF Pro: required for building custom blocks. The free version of ACF does not support this. ACF Pro is $49/year for a single site.

  • Local development environment (LocalWP, WordPress Studio, wp-env, or similar)

  • Any WordPress theme: block themes (Twenty Twenty-Four) or classic themes both work. No child theme required since we're using a plugin.

  • Basic PHP knowledge: you'll write PHP templates using standard ACF functions.

💡
Notes: This tutorial uses a separate plugin (not a child theme) so the blocks work regardless of which theme is active. This is the difference between our approach and ACF's official tutorial, which uses a child theme.

Step 1: Set Up the Plugin

The plugin contains all three blocks in one folder structure, organized so adding new blocks later is trivial.

1.1 - File structure

Create this folder inside wp-content/plugins/:

wpvibes-acf-blocks/
├── wpvibes-acf-blocks.php
└── blocks/
    ├── testimonial/
    │   ├── block.json
    │   ├── render.php
    │   └── style.css
    ├── feature-cta/
    │   ├── block.json
    │   ├── render.php
    │   └── style.css
    └── team-members/
        ├── block.json
        ├── render.php
        └── style.css
Enter fullscreen mode Exit fullscreen mode

Why this structure?

  • One block = one folder - every block owns its block.jsonrender.php, and style.css in the same place. Delete the folder and the block is gone.

  • Auto-registration - the main plugin file scans blocks/ and registers every folder it finds. Adding a new block never requires touching this file.

  • Per-block CSS - each block's styles are in its own file, loaded automatically when the block appears on a page.

This is the same pattern used in our PHP-only blocks guide - one block, one folder, self-contained.

1.2 - The main plugin file

Create wpvibes-acf-blocks.php:

<?php
/**
 * Plugin Name: WPVibes ACF Blocks
 * Description: Custom Gutenberg blocks built with Advanced Custom Fields — Testimonial, Feature CTA, and Team Members.
 * Version:     1.0.0
 * Author:      WPVibes
 * Requires at least: 6.7
 * Requires PHP: 7.4
 * Text Domain: wpvibes-acf-blocks
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Register all ACF blocks by scanning the blocks/ folder.
 * Adding a new block later = one new folder, zero changes here.
 */
add_action( 'init', 'wpvibes_acf_register_blocks' );

function wpvibes_acf_register_blocks() {

    // Only run if ACF Pro is active.
    if ( ! function_exists( 'acf_register_block_type' ) ) {
        return;
    }

    $blocks_dir = plugin_dir_path( __FILE__ ) . 'blocks/';

    if ( ! is_dir( $blocks_dir ) ) {
        return;
    }

    // Register every block folder inside blocks/.
    foreach ( glob( $blocks_dir . '*', GLOB_ONLYDIR ) as $block_folder ) {
        register_block_type( $block_folder );
    }
}
Enter fullscreen mode Exit fullscreen mode

What this does in plain words:

The plugin header identifies this folder as a WordPress plugin

wpvibes_acf_register_blocks() runs on WordPress's init hook

ACF Pro check first - if ACF Pro isn't active, the function exits silently (no fatal errors)

glob() finds every folder inside blocks/

register_block_type( $block_folder ) tells WordPress: "there's a block here, read block.json for details"

Once this loop exists, you never touch this file again. Every new block you add as a folder inside blocks/ registers automatically.

Activate the plugin from Plugins → Installed Plugins. Nothing visible yet - we haven't built any blocks.

Step 2: Build the Testimonial Block

The Testimonial block is the simplest of the three. It shows a customer quote with author details and photo. This block introduces the complete ACF Blocks workflow - block.json, field group setup, PHP template, and CSS.

Testimonial block rendered on the frontend

2.1 - Create block.json

Create blocks/testimonial/block.json:

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "wpvibes/testimonial",
    "title": "Testimonial",
    "description": "Display a customer testimonial with author details and photo.",
    "category": "text",
    "icon": "format-quote",
    "keywords": ["testimonial", "quote", "review", "author"],
    "supports": {
        "align": ["wide", "full"],
        "anchor": true,
        "jsx": true
    },
    "acf": {
        "mode": "preview",
        "renderTemplate": "render.php"
    },
    "style": "file:./style.css"
}
Enter fullscreen mode Exit fullscreen mode

Line by line, in plain words:

  • apiVersion: 3 - required for ACF Blocks V3. Always use v3 for new blocks.

  • name: "wpvibes/testimonial" - the block's unique identifier. Format: {namespace}/{block-slug}.

  • title, description, category, icon, keywords - how the block appears in the block inserter.

  • supports.align - allows wide and full-width alignment.

  • supports.anchor - lets users add an HTML anchor (for jump-to links).

  • supports.jsx: true - required for ACF Blocks V3.

  • acf.mode: "preview" - show the rendered block in the editor (not a form view).

  • acf.renderTemplate - the PHP file that renders the block. Relative to block.json.

  • style - the stylesheet, loaded automatically in both the editor and frontend.

2.2 - Create the ACF Field Group

The Testimonial block needs four fields. Create them through the ACF admin UI.

  • Go to wp-admin → ACF → Field Groups → Add New

  • Field group title: Testimonial Block Fields

  • Add the following four fields:

Field Label Field Name Field Type Notes
Testimonial Text testimonial_text Textarea Required, Character Limit: 500
Author Name author_name Text Required
Author Title author_title Text Optional, e.g., "CEO, Acme Corp"
Author Image author_image Image Return Format: Image Array, Preview Size: Medium

Create ACF Field Group for Testimonial Block

  • Scroll down to Settings → Location Rules

  • Set the rule: Show this field group if → Block → is equal to → Testimonial

  • Click Publish

Set ACF Field Group Settings

💡
Notes: The location rule (Block = Testimonial) is what tells ACF that these fields belong to this specific block. Without this exact rule, get_field() returns null and your block renders empty. This is the single most common mistake when building ACF Blocks. Confirm this rule is set correctly before moving on.

2.3 - Create render.php

Create blocks/testimonial/render.php:

<?php
/**
 * Testimonial block render template.
 *
 * @var array  $block      The block settings and attributes.
 * @var string $content    The block inner HTML (empty for ACF Blocks).
 * @var bool   $is_preview True during backend preview render.
 */

// Get field values.
$testimonial_text = get_field( 'testimonial_text' );
$author_name      = get_field( 'author_name' );
$author_title     = get_field( 'author_title' );
$author_image     = get_field( 'author_image' );

// Build CSS classes — always include the base class + any WordPress-added classes.
$class_name = 'wpvibes-testimonial';
if ( ! empty( $block['className'] ) ) {
    $class_name .= ' ' . $block['className'];
}
if ( ! empty( $block['align'] ) ) {
    $class_name .= ' align' . $block['align'];
}

// Support the anchor attribute.
$anchor = ! empty( $block['anchor'] ) ? 'id="' . esc_attr( $block['anchor'] ) . '" ' : '';
?>

<div <?php echo $anchor; ?>class="<?php echo esc_attr( $class_name ); ?>">

    <?php if ( $testimonial_text ) : ?>
        <blockquote class="wpvibes-testimonial__quote">
            <?php echo esc_html( $testimonial_text ); ?>
        </blockquote>
    <?php endif; ?>

    <div class="wpvibes-testimonial__author">

        <?php if ( $author_image ) : ?>
            <img
                class="wpvibes-testimonial__image"
                src="<?php echo esc_url( $author_image['sizes']['thumbnail'] ); ?>"
                alt="<?php echo esc_attr( $author_image['alt'] ); ?>"
                width="60"
                height="60"
            />
        <?php endif; ?>

        <div class="wpvibes-testimonial__info">
            <?php if ( $author_name ) : ?>
                <p class="wpvibes-testimonial__name"><?php echo esc_html( $author_name ); ?></p>
            <?php endif; ?>

            <?php if ( $author_title ) : ?>
                <p class="wpvibes-testimonial__title"><?php echo esc_html( $author_title ); ?></p>
            <?php endif; ?>
        </div>

    </div>

</div>
Enter fullscreen mode Exit fullscreen mode

Key points:

  • get_field() - ACF's standard function for retrieving field values. Works exactly the same in ACF Blocks as it does anywhere else in ACF.

  • $block['className'] - WordPress passes any classes the user added via the sidebar's Advanced section.

  • $block['align'] - the alignment the user picked (wide, full).

  • $block['anchor'] - the HTML ID the user set.

  • Every field wrapped in if ( $field ) - optional fields don't render empty tags.

  • esc_html() and esc_url() - always escape output. Standard WordPress security.

The $author_image array structure (when Return Format is "Image Array"):

  • $author_image['url'] - full-size URL

  • $author_image['sizes']['thumbnail'] - thumbnail URL

  • $author_image['sizes']['medium'] - medium URL

  • $author_image['alt'] - alt text

2.4 - Create style.css

Create blocks/testimonial/style.css:

.wpvibes-testimonial {
    background: #f9fafb;
    padding: 32px;
    border-radius: 12px;
    max-width: 640px;
    margin: 1.5em auto;
}

.wpvibes-testimonial__quote {
    font-size: 18px;
    line-height: 1.6;
    color: #1f2937;
    font-style: italic;
    margin: 0 0 24px;
    border: none;
}

.wpvibes-testimonial__author {
    display: flex;
    align-items: center;
    gap: 12px;
}

.wpvibes-testimonial__image {
    width: 60px;
    height: 60px;
    border-radius: 50%;
    object-fit: cover;
}

.wpvibes-testimonial__name {
    margin: 0;
    font-weight: 600;
    color: #111827;
}

.wpvibes-testimonial__title {
    margin: 0;
    font-size: 14px;
    color: #6b7280;
}
Enter fullscreen mode Exit fullscreen mode

💡
Notes: The full CSS (with hover states, quote decoration, responsive tweaks) is in the plugin repository at assets/blocks/testimonial/style.css. Copy the complete file into the plugin's blocks/testimonial/ folder before testing.

2.5 - See it working

  • Create a new page: Pages → Add New

  • Click + in the block inserter and search for Testimonial

  • Insert the block

  • Fill in the fields on the right sidebar:

    • Testimonial Text: "WPVibes plugins saved us weeks of development time. The support is fantastic."
    • Author Name: Sarah Chen
    • Author Title: Lead Developer, TechCorp
    • Author Image: upload a headshot
  • Watch the preview update in real time

  • Preview the frontend

Your first ACF block is done - no React, no build step, just PHP and CSS you already know.

Testimonial field group with all 4 fields and location rule visible

Step 3: Build the Feature CTA Block

The Feature CTA block introduces two new patterns - the Link field (URL + label + target in one field) and using images at different sizes. Same block structure as Testimonial, just different fields.

Feature CTA block rendered on the frontend

3.1 - Create block.json

Create blocks/feature-cta/block.json:

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "wpvibes/feature-cta",
    "title": "Feature CTA",
    "description": "A feature card with image, heading, description, and call-to-action button.",
    "category": "design",
    "icon": "megaphone",
    "keywords": ["feature", "cta", "call to action", "card"],
    "supports": {
        "align": ["wide", "full"],
        "anchor": true,
        "jsx": true
    },
    "acf": {
        "mode": "preview",
        "renderTemplate": "render.php"
    },
    "style": "file:./style.css"
}
Enter fullscreen mode Exit fullscreen mode

3.2 - Create the ACF Field Group

Create a new field group called Feature CTA Block Fields with these four fields:

Field Label Field Name Field Type Notes
Feature Image feature_image Image Return Format: Image Array
Heading heading Text Required
Description description Textarea Rows: 3
Button Link button_link Link Return Format: Link Array

Set the location rule: Show this field group if → Block → is equal to → Feature CTA.

💡
Notes: As with the Testimonial block, this Block = Feature CTA location rule is what connects the fields to the block. Missing this rule means get_field() returns null and the block renders empty. Confirm before continuing.

3.3 - Create render.php

Create blocks/feature-cta/render.php:

<?php
/**
 * Feature CTA block render template.
 *
 * @var array $block The block settings and attributes.
 */

$feature_image = get_field( 'feature_image' );
$heading       = get_field( 'heading' );
$description   = get_field( 'description' );
$button_link   = get_field( 'button_link' );

$class_name = 'wpvibes-feature-cta';
if ( ! empty( $block['className'] ) ) {
    $class_name .= ' ' . $block['className'];
}
if ( ! empty( $block['align'] ) ) {
    $class_name .= ' align' . $block['align'];
}
Enter fullscreen mode Exit fullscreen mode

The full render template, with the image and heading markup included, is here: blocks/feature-cta/render.php.

What's new here compared to Testimonial:

  • The Link field returns an array with three keys: urltitletarget. Handle each carefully with the appropriate escape function.

  • rel="noopener noreferrer" - security best practice when opening links in a new tab. Without this, the new tab can access the original window through window.opener.

  • Fallback for target - if the user didn't set a target, default to _self.

3.4 - Create style.css

Create blocks/feature-cta/style.css:

.wpvibes-feature-cta {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    gap: 20px;
    padding: 40px 32px;
    background: #ffffff;
    border-radius: 12px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
    max-width: 400px;
    margin: 1.5em auto;
}

.wpvibes-feature-cta__image {
    width: 80px;
    height: 80px;
}

.wpvibes-feature-cta__image img {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
}
Enter fullscreen mode Exit fullscreen mode

💡
Notes: The full CSS (hover effects, transitions, alignment overrides) is in the plugin repository at blocks/feature-cta/style.css.

3.5 - See it working

Insert Feature CTA from the Design category. Upload a small icon-style image, add a heading like "Fast Deployment," a description, and a button label with a URL. Preview the frontend.

Same pattern as Testimonial, more fields, one new field type. The plugin pattern is starting to feel automatic.

Feature CTA block filled in the editor

Step 4: Build the Team Members Block (with Repeater)

The Team Members block is where ACF Blocks really shines. Using ACF's Repeater field, users add unlimited team members through the sidebar - no coding required. This is the pattern that makes ACF Pro worth the license fee.

The block also introduces a column selector so users pick between 2, 3, or 4 columns.

Team Members grid rendered on the frontend

4.1 - Create block.json

Create blocks/team-members/block.json:

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "wpvibes/team-members",
    "title": "Team Members",
    "description": "Display a grid of team members using ACF Repeater fields.",
    "category": "design",
    "icon": "groups",
    "keywords": ["team", "members", "people", "staff"],
    "supports": {
        "align": ["wide", "full"],
        "anchor": true,
        "jsx": true
    },
    "acf": {
        "mode": "preview",
        "renderTemplate": "render.php"
    },
    "style": "file:./style.css"
}
Enter fullscreen mode Exit fullscreen mode

4.2 - Create the ACF Field Group

Create a field group called Team Members Block Fields with these fields:

Block-level fields (things that apply to the whole grid):

Field Label Field Name Field Type Notes
Section Heading section_heading Text Optional
Columns columns Text Enter the number of columns you want
Team Members team_members Repeater Layout: Block. Button Label: "Add Team Member"

Sub-fields inside the team_members repeater:

Sub-field Label Sub-field Name Sub-field Type Notes
Photo member_image Image Return Format: Image Array, Preview Size: Medium
Name member_name Text Required
Role member_role Text Optional
Bio member_bio Textarea Rows: 3, Optional

Set the location rule: Show this field group if → Block → is equal to → Team Members.

Team Members field group with the repeater and sub-fields visible

💡
Notes: The Block = Team Members location rule connects the fields to this block. Without it, no fields render in the sidebar and have_rows() returns false. Confirm the rule before writing render code.

💡
Tips: When adding the Repeater field, click Add Sub Field inside the repeater to add each column. Sub-fields live INSIDE the repeater, not next to it.

4.3 - Create render.php

Create blocks/team-members/render.php:

<?php
/**
 * Team Members block render template.
 *
 * @var array $block The block settings and attributes.
 */

// Block-level fields.
$section_heading = get_field( 'section_heading' );
$columns         = get_field( 'columns' );
$columns         = $columns ? $columns : 3;

// Build CSS classes.
$class_name = 'wpvibes-team wpvibes-team--cols-' . intval( $columns );
if ( ! empty( $block['className'] ) ) {
    $class_name .= ' ' . $block['className'];
}
if ( ! empty( $block['align'] ) ) {
    $class_name .= ' align' . $block['align'];
}

$anchor = ! empty( $block['anchor'] ) ? 'id="' . esc_attr( $block['anchor'] ) . '" ' : '';
?>
Enter fullscreen mode Exit fullscreen mode

The full render template, with every sub-field's markup included, is here: blocks/team-members/render.php.

Three ACF Pro functions doing the heavy lifting:

have_rows( 'team_members' ) - checks if the repeater has any rows. Similar in shape to WordPress's have_posts().

the_row() - loads the current row's data so get_sub_field() calls work.

get_sub_field( 'field_name' ) - retrieves a sub-field value from the current row.

Once you understand this three-function loop, you can build any repeater-based block - image galleries, feature lists, pricing tiers, timelines, FAQ accordions. The pattern is always the same.

4.4 - Create style.css

Create blocks/team-members/style.css:

.wpvibes-team {
    margin: 2em auto;
}

.wpvibes-team__grid {
    display: grid;
    gap: 32px;
}

.wpvibes-team--cols-2 .wpvibes-team__grid { grid-template-columns: repeat(2, 1fr); }
.wpvibes-team--cols-3 .wpvibes-team__grid { grid-template-columns: repeat(3, 1fr); }
.wpvibes-team--cols-4 .wpvibes-team__grid { grid-template-columns: repeat(4, 1fr); }

.wpvibes-team__member {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    padding: 32px 24px;
    background: #ffffff;
    border-radius: 12px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
Enter fullscreen mode Exit fullscreen mode

💡
Notes: The full CSS (with hover effects, responsive breakpoints for mobile, and better typography) is in the plugin repository at blocks/team-members/style.css.

4.5 - See it working

  • Insert Team Members from the Design category

  • Set section heading: "Meet Our Team"

  • Set columns to 3

  • Click Add Team Member - add several members with photo, name, role, and bio

  • Watch the grid render in the editor preview

  • Try changing columns to 2 or 4 to see the layout adapt

Team Members block in the editor with 4 members and column selector visible

Three real, production-ready blocks in one plugin - done.

Get the Full Plugin

The complete plugin - with all three blocks, full CSS, and README - is on GitHub:

To install:

cd wp-content/plugins/
git clone https://github.com/webtechhardik/wpvibes-acf-blocks.git
Enter fullscreen mode Exit fullscreen mode

Then activate from Plugins → Installed Plugins. Adding a new block:

  1. Create a new folder inside blocks/ (e.g., blocks/pricing-table/)

  2. Add block.jsonrender.php, and style.css to it

  3. Create the matching ACF field group with location rule Block = Pricing Table

That's it. No changes to wpvibes-acf-blocks.php, no manual registration - the loader handles everything.

Why ACF Blocks Makes Block Development Easy

Building custom Gutenberg blocks the "official" way means learning React, setting up a JavaScript build pipeline (Webpack, Babel, Node.js), and mastering the block editor's JavaScript APIs. That's a real learning curve - several weeks of investment before shipping the first block.

ACF Blocks removes almost all of that. If you already know PHP and use ACF on your projects, the entire mental shift is small:

  • You write PHP templates - the same syntax you've been writing for years

  • You use get_field() - the same function you already use everywhere else

  • ACF handles the editor experience - field UI, live preview, validation, all automatic

  • No build step - save PHP, refresh the browser

The tradeoff is that ACF Pro costs $49/year for a single site. For agencies and freelancers building client sites, that's a rounding error compared to the time saved on every project.

For blocks that need advanced editor experiences (drag-and-drop, deep interactivity, custom media handling), React blocks still make sense. But for the vast majority of custom blocks a WordPress site actually needs - testimonials, feature cards, team grids, pricing tables, hero sections - ACF Blocks does the job faster with less friction.

Frequently Asked Questions

Do I need ACF Pro, or does the free version work?

ACF Pro is required. Custom block registration is a Pro-only feature. The free ACF plugin supports custom fields on posts and pages, but not custom Gutenberg blocks. ACF Pro is $49/year for a single site.

Can I use ACF Blocks in a child theme instead of a plugin?

Yes. The official ACF tutorial uses this approach. The plugin approach shown in this guide is better for portability - the blocks work regardless of which theme is active.

Why does get_field() return null in my ACF Block?

The most common cause is the field group's location rule. It must be set exactly to Block = [Block Name]. If the rule says "Post Type" or anything else, the fields don't attach to the block. This trips up nearly every developer at least once.

Can ACF Blocks use InnerBlocks (nested blocks)?

Yes. Add <InnerBlocks /> to your render.php template. ACF officially supports this in ACF Blocks V3. This gives you container blocks where users can nest core blocks inside.

How do I add a custom icon instead of a Dashicon?

Two ways. Use any Dashicon slug in block.json's icon field (like format-quote or groups). Or, with WordPress 7.1+, register your own SVG icon and reference it. See our Custom SVG Icons guide for the full process.

What's the difference between ACF Blocks and PHP-only blocks (WordPress 7.0+) ?

ACF Blocks require ACF Pro but support 30+ field types (repeater, image, gallery, relationship, etc.). WordPress 7.0's PHP-only blocks are free but limited to 4 attribute types (string, integer, boolean, enum). For simple blocks, PHP-only works. For anything with images, repeaters, or complex data, ACF Blocks wins.

How do I loop through a repeater on the frontend?

Use the standard ACF repeater loop: have_rows()the_row(), and get_sub_field(). This pattern works exactly the same in an ACF Block as it does anywhere else in ACF. The Team Members block in this guide is a full working example.

Can I ship an ACF field group in the plugin so users don't have to create it manually?

Yes, using ACF's JSON sync feature. Create an acf-json/ folder in the plugin root. When users save a field group, ACF writes it to that folder. Include those JSON files in the plugin zip. When a user activates the plugin, ACF loads the field group automatically. This is the approach ACF's official documentation recommends for shipping fields with plugins or themes.

Wrapping Up

Custom Gutenberg blocks don't require learning React. If you know PHP and use ACF on your projects, you can start shipping real blocks today - with the same familiar workflow you already use for everything else.

The takeaways:

  1. The pattern is consistent - every ACF block is block.json + field group + render.php + style.css in one folder

  2. The location rule is critical - always set the field group to Block = [Block Name], or get_field() returns null

  3. One block = one folder - self-contained, easy to add, easy to remove

  4. Auto-registration scales - the plugin scans blocks/ and registers every folder, so adding a new block requires zero changes to existing files

  5. The Repeater field is the payoff - three functions (have_rowsthe_rowget_sub_field) unlock unlimited grid layouts, team lists, pricing tiers, and more

Top comments (0)