Skip to main content

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.

Learning Focus

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.

Core Idea

Treat block.json as a schema contract: validate required metadata, register deterministically, and verify render-template integrity before release.

Why It Matters

ApproachWhat HappensImpact in Production
Metadata-first block definitionsBlock settings are centralized and diff-friendlyBetter review quality and lower config drift
Validate block.json in CIMissing keys/path errors are caught earlyFewer runtime surprises after deploy
Register blocks from predictable path structureBootstrap code stays simple and auditableEasier maintenance across many blocks
Keep block name/field-location alignmentField groups load in correct editor contextConsistent authoring and reliable rendering
Skip metadata validation (wrong pattern)Broken block config reaches productionBlank blocks, support overhead, release rollback risk

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
block.jsonname, title, category, supports, acfCanonical metadata contract for blockKeep namespaced name immutable after use
ACF metadata key"acf": { "mode": "preview", "renderTemplate": "..." }Configure ACF render behaviorACF Pro Required for ACF block metadata behavior
register_block_type()`register_block_type(string $path, array $args = []): WP_Block_Typefalse`Register block from metadata path
Bootstrap hookadd_action('init', callable $callback): voidRegister block metadata during WP initKeep registration in one bootstrap file
Metadata validationjson.loads(...) / schema checksDetect missing keys and invalid structuresRun in CI and local pre-commit checks
Render template pathacf.renderTemplatePHP file that outputs block markupValidate file existence in release checks
wp evalwp eval '<php-code>'Runtime checks for hooks/functionsGood for smoke checks in CLI pipelines
has_block()`has_block(string $block_name, WP_Poststringnull $post = null): bool`
ACF Pro Required

The acf block metadata integration and ACF field-backed render templates in this lesson rely on ACF Pro.

block.json Schema

wp-content/themes/clinic-pro/blocks/marketing-cta/block.json
{
"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.

  1. Create a block directory with block.json and render.php.
  2. Add one bootstrap registration file.
  3. Register block path on init.
  4. Validate metadata keys and path references.
  5. Verify registration prerequisites via CLI.
wp-content/themes/clinic-pro/inc/acf/register-block-json.php
<?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']);
});
wp-content/themes/clinic-pro/blocks/marketing-cta/render.php
<?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>';
terminal: command
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
terminal: output
clinic-pro/marketing-cta
blocks/marketing-cta/render.php
init-hooked
wp-content/themes/clinic-pro/blocks/marketing-cta/render.php
warning

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.

  1. Scan all block directories for block.json.
  2. Parse each file and check required keys.
  3. Validate namespaced block name format.
  4. Resolve and verify render template file existence.
  5. Fail CI if any block violates contract.
scripts/validate-block-metadata.php
<?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;
terminal: command
php scripts/validate-block-metadata.php
terminal: output
All block metadata checks passed
note

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

wp-content/themes/clinic-pro/inc/acf/block-location-fragile.php
<?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

wp-content/themes/clinic-pro/inc/acf/block-location-robust.php
<?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);
});
terminal: command
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
terminal: output
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
warning

Renaming block slugs requires synchronized updates across block.json, field-group location rules, and migration checks.

Common Mistakes

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Missing acf.renderTemplate in metadataIncomplete block.json authoringBlock registers but render output failsValidate metadata keys in CI
Block name not namespacedNo naming policyCollisions with third-party blocksEnforce vendor/block-name pattern
Render template path typoNo path existence checkBlock appears with blank outputVerify file path in validation script
block.json name and field location divergeUnsynchronized renameBlock fields do not appear in editorStore and verify block-location contract
Registration spread across many filesNo central bootstrap patternHard auditing and driftUse one block registration bootstrap file
No runtime smoke checksCI validates JSON onlyHook/load issues missed until editor usageAdd 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

  1. Use one directory per block (blocks/<slug>/) with block.json + render.php.
  2. Validate metadata keys and render paths in CI on every PR.
  3. Keep block names namespaced and immutable after content adoption.
  4. Register blocks from deterministic bootstrap paths on init.
  5. Store block-location contracts and verify them in release checks.
  6. Treat metadata and schema changes as one atomic change set.
  7. 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

CommandPurposeReal Example Output
python3 - <<'PY2' ... PY2Parse and inspect block.json metadata keysclinic-pro/marketing-cta
php scripts/validate-block-metadata.phpValidate all block metadata contractsAll block metadata checks passed
wp eval 'echo has_action("init") ? "yes" : "no"; echo PHP_EOL;'Verify bootstrap hook loadedyes
wp eval 'print_r(get_option("acf_block_location_contract"));'Inspect stored block-location contractArray ( [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 ruleparam => block
ls -1 wp-content/themes/clinic-pro/blocks/marketing-cta/render.phpConfirm render template path existswp-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 blocktrue or false
`wp plugin list --status=activegrep advanced-custom-fields-pro`Confirm ACF Pro dependency state

What's Next

tip

Revisit this lesson when introducing a new custom block to production. Most block incidents come from metadata drift, not from complex PHP logic.