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
Why this structure?
One block = one folder - every block owns its
block.json,render.php, andstyle.cssin 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 );
}
}
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.
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"
}
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 toblock.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 |
Scroll down to Settings → Location Rules
Set the rule: Show this field group if → Block → is equal to → Testimonial
Click Publish
💡
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>
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()andesc_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;
}
💡
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
- Testimonial Text:
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.
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.
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"
}
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'];
}
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:
url,title,target. 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 throughwindow.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;
}
💡
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.
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.
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"
}
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.
💡
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'] ) . '" ' : '';
?>
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);
}
💡
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
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
Then activate from Plugins → Installed Plugins. Adding a new block:
Create a new folder inside
blocks/(e.g.,blocks/pricing-table/)Add
block.json,render.php, andstyle.cssto itCreate 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 elseACF 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:
The pattern is consistent - every ACF block is
block.json+ field group +render.php+style.cssin one folderThe location rule is critical - always set the field group to
Block = [Block Name], orget_field()returns nullOne block = one folder - self-contained, easy to add, easy to remove
Auto-registration scales - the plugin scans
blocks/and registers every folder, so adding a new block requires zero changes to existing filesThe Repeater field is the payoff - three functions (
have_rows,the_row,get_sub_field) unlock unlimited grid layouts, team lists, pricing tiers, and more









Top comments (0)