Registering ACF Blocks with block.json
block.json turns custom block registration into a metadata contract that is easier to review, validate, and deploy consistently.
You will implement metadata-first ACF block registration with block.json, build validation checks for required keys and render paths, and harden release workflows against common registration drift.
Concept Overview
Metadata-first registration moves critical block properties from scattered PHP arrays into a standard file (block.json). This improves discoverability, aligns with modern Gutenberg patterns, and lets CI validate block contracts without booting full editor UI.
In ACF Pro block workflows, the acf key inside block.json defines rendering mode and template path. This metadata must stay synchronized with field-group location rules and template files. If metadata and field schema diverge, blocks can register successfully but still fail in editor or frontend.
A robust workflow combines metadata validation, path existence checks, and runtime smoke tests for bootstrap hooks. CLI cannot fully simulate editor interactions, but it can verify that files, hooks, and registration prerequisites are present and consistent.
Treat block.json as a schema contract: validate required metadata, register deterministically, and verify render-template integrity before release.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
| Metadata-first block definitions | Block settings are centralized and diff-friendly | Better review quality and lower config drift |
Validate block.json in CI | Missing keys/path errors are caught early | Fewer runtime surprises after deploy |
| Register blocks from predictable path structure | Bootstrap code stays simple and auditable | Easier maintenance across many blocks |
| Keep block name/field-location alignment | Field groups load in correct editor context | Consistent authoring and reliable rendering |
| Skip metadata validation (wrong pattern) | Broken block config reaches production | Blank blocks, support overhead, release rollback risk |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
block.json | name, title, category, supports, acf | Canonical metadata contract for block | Keep namespaced name immutable after use |
| ACF metadata key | "acf": { "mode": "preview", "renderTemplate": "..." } | Configure ACF render behavior | ACF Pro Required for ACF block metadata behavior |
register_block_type() | `register_block_type(string $path, array $args = []): WP_Block_Type | false` | Register block from metadata path |
| Bootstrap hook | add_action('init', callable $callback): void | Register block metadata during WP init | Keep registration in one bootstrap file |
| Metadata validation | json.loads(...) / schema checks | Detect missing keys and invalid structures | Run in CI and local pre-commit checks |
| Render template path | acf.renderTemplate | PHP file that outputs block markup | Validate file existence in release checks |
wp eval | wp eval '<php-code>' | Runtime checks for hooks/functions | Good for smoke checks in CLI pipelines |
has_block() | `has_block(string $block_name, WP_Post | string | null $post = null): bool` |
The acf block metadata integration and ACF field-backed render templates in this lesson rely on ACF Pro.
block.json Schema
{
"name": "clinic-pro/marketing-cta",
"title": "Marketing CTA",
"description": "Configurable CTA block with heading, body, and action link",
"category": "widgets",
"icon": "megaphone",
"keywords": ["cta", "marketing", "conversion"],
"supports": {
"align": ["wide", "full"],
"anchor": true,
"jsx": true
},
"acf": {
"mode": "preview",
"renderTemplate": "blocks/marketing-cta/render.php"
}
}
Practical Use Cases
Use Case 1 — Register one ACF block via block.json and deterministic bootstrap
A product team wants a reusable CTA block with metadata reviewed in Git and no ad-hoc registration arrays in random files.
- Create a block directory with
block.jsonandrender.php. - Add one bootstrap registration file.
- Register block path on
init. - Validate metadata keys and path references.
- Verify registration prerequisites via CLI.
<?php
declare(strict_types=1);
add_action('init', function (): void {
$blockPath = get_stylesheet_directory() . '/blocks/marketing-cta';
if (!is_dir($blockPath)) {
error_log('[block-json] missing_block_dir=' . $blockPath);
return;
}
$jsonPath = $blockPath . '/block.json';
if (!file_exists($jsonPath)) {
error_log('[block-json] missing_block_json=' . $jsonPath);
return;
}
$payload = json_decode((string) file_get_contents($jsonPath), true);
if (!is_array($payload) || empty($payload['name'])) {
error_log('[block-json] invalid_metadata=' . $jsonPath);
return;
}
$result = register_block_type($blockPath);
if (!$result) {
error_log('[block-json] registration_failed=' . $blockPath);
return;
}
error_log('[block-json] registered=' . (string) $payload['name']);
});
<?php
declare(strict_types=1);
$heading = (string) get_field('cta_heading');
$body = (string) get_field('cta_body');
$label = (string) get_field('cta_label');
$url = (string) get_field('cta_url');
if ($heading === '' && $body === '') {
echo '<div class="acf-block-preview">Add CTA heading/body content.</div>';
return;
}
echo '<section class="marketing-cta">';
if ($heading !== '') {
echo '<h2>' . esc_html($heading) . '</h2>';
}
if ($body !== '') {
echo '<p>' . esc_html($body) . '</p>';
}
if ($label !== '' && $url !== '') {
echo '<a class="button" href="' . esc_url($url) . '">' . esc_html($label) . '</a>';
}
echo '</section>';
python3 - <<'PY2'
import json
from pathlib import Path
f = Path('wp-content/themes/clinic-pro/blocks/marketing-cta/block.json')
data = json.loads(f.read_text())
print(data['name'])
print(data['acf']['renderTemplate'])
PY2
wp eval 'echo has_action("init") ? "init-hooked" : "init-missing"; echo PHP_EOL;'
ls -1 wp-content/themes/clinic-pro/blocks/marketing-cta/render.php
clinic-pro/marketing-cta
blocks/marketing-cta/render.php
init-hooked
wp-content/themes/clinic-pro/blocks/marketing-cta/render.php
Do not trust JSON parse success alone. Key presence and path existence checks are equally important.
Use Case 2 — Build CI validation for all block.json metadata
A theme has 12 custom blocks. You need a CI gate that validates required keys and ensures every acf.renderTemplate file exists.
- Scan all block directories for
block.json. - Parse each file and check required keys.
- Validate namespaced block name format.
- Resolve and verify render template file existence.
- Fail CI if any block violates contract.
<?php
declare(strict_types=1);
$base = __DIR__ . '/../wp-content/themes/clinic-pro/blocks';
$iterator = new DirectoryIterator($base);
$requiredKeys = ['name', 'title', 'category', 'acf'];
$errors = [];
foreach ($iterator as $dir) {
if ($dir->isDot() || !$dir->isDir()) {
continue;
}
$jsonPath = $dir->getPathname() . '/block.json';
if (!file_exists($jsonPath)) {
$errors[] = $dir->getFilename() . ': missing block.json';
continue;
}
$data = json_decode((string) file_get_contents($jsonPath), true);
if (!is_array($data)) {
$errors[] = $dir->getFilename() . ': invalid JSON';
continue;
}
foreach ($requiredKeys as $key) {
if (!array_key_exists($key, $data)) {
$errors[] = $dir->getFilename() . ': missing key ' . $key;
}
}
$name = (string) ($data['name'] ?? '');
if (!str_contains($name, '/')) {
$errors[] = $dir->getFilename() . ': block name is not namespaced';
}
$renderTemplate = (string) ($data['acf']['renderTemplate'] ?? '');
if ($renderTemplate === '') {
$errors[] = $dir->getFilename() . ': missing acf.renderTemplate';
continue;
}
$renderPath = __DIR__ . '/../wp-content/themes/clinic-pro/' . $renderTemplate;
if (!file_exists($renderPath)) {
$errors[] = $dir->getFilename() . ': missing render template ' . $renderTemplate;
}
}
if (count($errors) > 0) {
foreach ($errors as $error) {
echo $error . PHP_EOL;
}
exit(1);
}
echo 'All block metadata checks passed' . PHP_EOL;
php scripts/validate-block-metadata.php
All block metadata checks passed
Metadata validation is cheap and catches many breakages before WordPress runtime is even loaded.
Use Case 3 — Edge case: block name and field-group location mismatch
A block is renamed in block.json from clinic-pro/marketing-cta to clinic-pro/conversion-cta, but field group location rule still targets the old block slug. Result: block appears, but fields are missing.
❌ Fragile Pattern
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
acf_add_local_field_group([
'key' => 'group_marketing_cta_fields',
'title' => 'Marketing CTA Fields',
'fields' => [
['key' => 'field_cta_heading', 'label' => 'Heading', 'name' => 'cta_heading', 'type' => 'text'],
],
'location' => [[[
'param' => 'block',
'operator' => '==',
'value' => 'acf/marketing-cta',
]]],
]);
});
✅ Robust Pattern
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
$blockName = 'acf/marketing-cta';
acf_add_local_field_group([
'key' => 'group_marketing_cta_fields',
'title' => 'Marketing CTA Fields',
'fields' => [
['key' => 'field_cta_heading', 'label' => 'Heading', 'name' => 'cta_heading', 'type' => 'text'],
['key' => 'field_cta_body', 'label' => 'Body', 'name' => 'cta_body', 'type' => 'textarea'],
['key' => 'field_cta_label', 'label' => 'Label', 'name' => 'cta_label', 'type' => 'text'],
['key' => 'field_cta_url', 'label' => 'URL', 'name' => 'cta_url', 'type' => 'url'],
],
'location' => [[[
'param' => 'block',
'operator' => '==',
'value' => $blockName,
]]],
]);
update_option('acf_block_location_contract', [
'group_key' => 'group_marketing_cta_fields',
'block_location_value' => $blockName,
'checked_at' => gmdate('c'),
], false);
});
wp eval 'print_r(get_option("acf_block_location_contract"));'
wp eval '$groups=acf_get_field_groups(["key"=>"group_marketing_cta_fields"]); print_r($groups[0]["location"]);'
python3 - <<'PY2'
import json
from pathlib import Path
p = Path('wp-content/themes/clinic-pro/blocks/marketing-cta/block.json')
d = json.loads(p.read_text())
print(d['name'])
PY2
Array
(
[group_key] => group_marketing_cta_fields
[block_location_value] => acf/marketing-cta
[checked_at] => 2026-02-23T13:10:51+00:00
)
Array
(
[0] => Array
(
[0] => Array
(
[param] => block
[operator] => ==
[value] => acf/marketing-cta
)
)
)
clinic-pro/marketing-cta
Renaming block slugs requires synchronized updates across block.json, field-group location rules, and migration checks.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
Missing acf.renderTemplate in metadata | Incomplete block.json authoring | Block registers but render output fails | Validate metadata keys in CI |
| Block name not namespaced | No naming policy | Collisions with third-party blocks | Enforce vendor/block-name pattern |
| Render template path typo | No path existence check | Block appears with blank output | Verify file path in validation script |
| block.json name and field location diverge | Unsynchronized rename | Block fields do not appear in editor | Store and verify block-location contract |
| Registration spread across many files | No central bootstrap pattern | Hard auditing and drift | Use one block registration bootstrap file |
| No runtime smoke checks | CI validates JSON only | Hook/load issues missed until editor usage | Add CLI checks for hooks and options contracts |
Deep Dive: Why block.json Drift Is Harder to Catch Than It Looks
Metadata drift often passes visual code review because each file looks valid on its own. block.json may be correct, field-group location may be correct, and render template may exist, but the values can still disagree with each other. These mismatches typically appear only when editors insert the block and see no fields or no output. Automated cross-file checks are the practical answer: compare block name, location rules, and render path in one validation pass.
wp eval '$g=acf_get_field_groups(["key"=>"group_marketing_cta_fields"]); print_r($g[0]["location"]);'
Best Practices
- Use one directory per block (
blocks/<slug>/) withblock.json+render.php. - Validate metadata keys and render paths in CI on every PR.
- Keep block names namespaced and immutable after content adoption.
- Register blocks from deterministic bootstrap paths on
init. - Store block-location contracts and verify them in release checks.
- Treat metadata and schema changes as one atomic change set.
- Add CLI smoke checks even when JSON validation passes.
Hands-On Practice
Exercise 1: Create and validate one block.json
Create wp-content/themes/clinic-pro/blocks/marketing-cta/block.json and run:
python3 - <<'PY2'
import json
from pathlib import Path
f = Path('wp-content/themes/clinic-pro/blocks/marketing-cta/block.json')
d = json.loads(f.read_text())
print(d['name'])
print(d['acf']['renderTemplate'])
PY2
After completing this exercise, output should be:
clinic-pro/marketing-cta
blocks/marketing-cta/render.php
Exercise 2: Register metadata path and verify init hook
Create bootstrap registration file and run:
wp eval 'echo has_action("init") ? "init-hooked" : "init-missing"; echo PHP_EOL;'
After completing this exercise, output should be:
init-hooked
Exercise 3: Run full block metadata validator
Create scripts/validate-block-metadata.php and run:
php scripts/validate-block-metadata.php
After completing this exercise, output should be:
All block metadata checks passed
Exercise 4: Verify block-location alignment
Run:
wp eval 'print_r(get_option("acf_block_location_contract"));'
wp eval '$g=acf_get_field_groups(["key"=>"group_marketing_cta_fields"]); print_r($g[0]["location"]);'
After completing this exercise, output should include:
[block_location_value] => acf/marketing-cta
[param] => block
Exercise 5: Add release smoke checks for block bootstrapping
Run:
wp eval 'echo has_action("init") ? "init=1" : "init=0"; echo PHP_EOL;'
wp eval 'echo has_filter("acf/blocks/no_fields_assigned_message") ? "diag=1" : "diag=0"; echo PHP_EOL;'
ls -1 wp-content/themes/clinic-pro/blocks/marketing-cta/render.php
After completing this exercise, output pattern should be:
init=1
diag=1
wp-content/themes/clinic-pro/blocks/marketing-cta/render.php
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
python3 - <<'PY2' ... PY2 | Parse and inspect block.json metadata keys | clinic-pro/marketing-cta |
php scripts/validate-block-metadata.php | Validate all block metadata contracts | All block metadata checks passed |
wp eval 'echo has_action("init") ? "yes" : "no"; echo PHP_EOL;' | Verify bootstrap hook loaded | yes |
wp eval 'print_r(get_option("acf_block_location_contract"));' | Inspect stored block-location contract | Array ( [block_location_value] => acf/marketing-cta ) |
wp eval '$g=acf_get_field_groups(["key"=>"group_marketing_cta_fields"]); print_r($g[0]["location"]);' | Verify field-group block location rule | param => block |
ls -1 wp-content/themes/clinic-pro/blocks/marketing-cta/render.php | Confirm render template path exists | wp-content/themes/clinic-pro/blocks/marketing-cta/render.php |
wp eval 'var_export(has_block("clinic-pro/marketing-cta", get_post(1360))); echo PHP_EOL;' | Check if a post uses the block | true or false |
| `wp plugin list --status=active | grep advanced-custom-fields-pro` | Confirm ACF Pro dependency state |
What's Next
- Continue to InnerBlocks, Editor Guardrails, and Preview Quality.
- Return to Module 4 Overview to review full block architecture.
- Related lesson: ACF Blocks Core Concepts.
- Related lesson: Local JSON and Version Control Workflow.
Revisit this lesson when introducing a new custom block to production. Most block incidents come from metadata drift, not from complex PHP logic.