Block Bindings API: Custom Fields in Core Blocks

Block Bindings API: Custom Fields in Core Blocks

The Block Bindings API connects a stored value — usually a custom field — to an attribute on a core block, so a Paragraph can print post_meta with no shortcode, no custom block, and no PHP in a template file. It landed in WordPress 6.5 and it is the machinery underneath pattern overrides, so if you have ever marked a block as overridable inside a synced pattern, you have already shipped a binding.

What it is not: a field UI, a templating language, or a way to render a repeater. It swaps one value into one attribute at render time. That narrowness is the whole reason it is reliable, and it is also the reason half the people who try it give up on day two.

The sixty-second version

Register your meta key with show_in_rest => true, 'type' => 'string', and no leading underscore. Select a Paragraph, Heading, Image or Button in the editor, open the Attributes panel in the block inspector, and pick the field. That is the entire happy path. If your field does not appear in that list, it is almost always one of those three registration details — jump to Connect a custom field below.

What the Block Bindings API actually does

A binding is a note in the block’s comment that says “when you render this attribute, go and ask this source for the value instead”. Nothing more. Here is a Paragraph bound to a meta key:

<!-- wp:paragraph {"metadata":{"bindings":{
  "content":{"source":"core/post-meta","args":{"key":"wpfairs_reading_time"}}
}}} -->
<p>About 5 minutes</p>
<!-- /wp:paragraph -->

Three things are worth noticing in that snippet, because each one explains a behaviour that surprises people later.

The bindings live under metadata, keyed by attribute name. One block can bind several attributes to several sources — an Image can take its url from one field and its alt from another, independently.

The HTML between the delimiters is still there. “About 5 minutes” is not dead weight; it is what renders if the source returns nothing, and it is what the block falls back to in contexts where the source cannot resolve. Treat it as a deliberate default rather than leftover placeholder text.

The substitution happens at render, in PHP. The stored markup never changes when the field changes. That is good for the obvious reason — edit the custom field and every post using the binding updates — and it also means bound blocks sidestep the usual static-block validation problem entirely. If you have fought with a block that contains unexpected or invalid content, the mismatch there is between stored HTML and a save function; a bound attribute is resolved after all that, so it is not part of the comparison.

Which blocks and attributes support bindings

This is the first wall most people hit, so check it before you design anything around the API. The list is short and deliberate. On a narrow screen the table stacks into one card per block.

BlockBindable attributesWhat that gets you in practice
core/paragraphcontentThe workhorse. Any single string — a price, a model number, a review date
core/headingcontentA per-post subtitle or product name without a custom template
core/imageid, url, title, alt, captionThe most useful one. Bind url and alt to two separate fields and you have a per-post hero that editors cannot style wrong
core/buttonurl, text, linkTarget, relA per-post CTA — an affiliate link, a booking URL — with the design locked in the pattern
core/list-itemcontentAdded in WordPress 7.1, along with a fix that stops a nested sub-list being dropped when a bound item renders
core/post-datedatetimeShow a “last reviewed” date from a field instead of the post’s modified date
core/navigation-link, core/navigation-submenuurlNarrow, but it is how you drive a menu destination from a stored setting
Attribute coverage per the Block Bindings page in the Block Editor Handbook; the List Item addition per the WordPress 7.1 release notes.

Since WordPress 6.9 you can widen that list yourself with the block_bindings_supported_attributes filter — the Handbook’s own example adds linkDestination to the Image block. It does not let you make a block bindable that is not already on the list, so it extends the table above rather than replacing it.

Pattern overrides support fewer blocks than bindings do

These two lists get conflated constantly. Pattern overrides are built on bindings and use the core/pattern-overrides source, but they are offered on four block types only: Heading, Paragraph, Image and Buttons. List Item and Post Date support bindings without being offered as overrides. If you are choosing a reuse mechanism rather than a data source, synced patterns vs template parts works through that decision separately.

Connect a custom field to a block in four steps

1. Register the meta key properly

An unregistered meta key does not exist as far as the core/post-meta source is concerned. Put this in a small site plugin rather than in functions.php, so the field survives a theme change:

<?php
add_action( 'init', function () {
	register_post_meta( 'post', 'wpfairs_reading_time', array(
		'show_in_rest'      => true,
		'single'            => true,
		'type'              => 'string',
		'default'           => '',
		'sanitize_callback' => 'wp_strip_all_tags',
		'auth_callback'     => function () {
			return current_user_can( 'edit_posts' );
		},
	) );
} );

Each argument there is load-bearing. show_in_rest is what exposes the value to the editor at all — without it the field is invisible to the block editor and to the REST API. single means one value rather than an array, which is what a bound attribute can consume. 'type' => 'string' is the type the editor’s field list offers; register it as an integer or an array and it will not appear. And the first argument is the post type, so repeat the call for each post type that needs the field.

Underscore-prefixed keys are off limits to core/post-meta

A key beginning with _ is protected meta, and the core/post-meta source will not read it — this is the single most common reason a binding silently renders the fallback text. It is also why so much existing plugin data is unreachable this way: most plugins prefix their keys with an underscore deliberately, to keep them out of the old Custom Fields box. You can still read those values, but you have to register your own source, which the next section covers.

2. Give the field somewhere to be edited

Registering meta creates storage, not a text box. You need one of: the classic Custom Fields panel (enabled from the editor’s Preferences → General), a field plugin, a small block editor plugin of your own, or — often the quickest — nothing at all, because the bound block itself is editable when the source allows it and the current user has permission. For the core/post-meta source, typing into a bound Paragraph writes back to the field.

3. Bind the attribute

Select the block, open the block inspector, and find the Attributes panel. Choose the attribute, then choose the field. The editor writes the metadata.bindings JSON for you.

You can also hand-write it in the Code editor (Ctrl/Cmd + Shift + Alt + M), and for a template file you will have to. Get the JSON wrong — a trailing comma, an unquoted key, a missing brace — and the editor reports the block as invalid rather than telling you the binding is malformed, which sends people hunting in the wrong place entirely.

4. Check what happens when the field is empty

Do this deliberately, on a post where the field has never been filled in. With no value, the editor shows a placeholder and marks the block as connected rather than letting you type into it freely; on the front end you get the registered default if you set one, and the stored fallback HTML otherwise. An empty bound Image is the case to test hardest, because an unset url is visibly different from an unset paragraph.

Bindings inside a Query Loop

This is the part that makes the API worth the setup, and it is easy to miss. The core/post-meta source is registered with uses_context of postId and postType, so a bound block inside a Query Loop resolves against whichever post that iteration is rendering — not against the page the loop sits on.

Practically: build one card layout in a Query Loop template, bind a Heading to a subtitle field and a Button’s url to a link field, and every card fills itself in. The same markup works in a single template, an archive template, or a synced pattern dropped into a post. If you have been building that kind of thing with a custom block or a shortcode, this is the replacement, and it keeps the layout editable in the Site Editor instead of hiding it in PHP. Where that markup belongs in a block theme is its own question — customising a block theme with theme.json or a child theme covers the cascade.

Registering your own binding source in PHP

Your own source is the answer to every “but my data is not a plain post meta string” problem: protected keys, options, term meta, a formatted date, a computed value, a field inside a serialised array. Register it on init:

<?php
add_action( 'init', function () {
	register_block_bindings_source( 'wpfairs/last-reviewed', array(
		'label'              => __( 'Last reviewed', 'wpfairs' ),
		'uses_context'       => array( 'postId' ),
		'get_value_callback' => function ( $source_args, $block_instance, $attribute_name ) {
			$post_id = $block_instance->context['postId'] ?? get_the_ID();

			if ( ! $post_id ) {
				return null;
			}

			// A protected key, which core/post-meta refuses to read.
			$stamp = get_post_meta( $post_id, '_wpfairs_reviewed_gmt', true );

			if ( ! $stamp ) {
				return null;
			}

			$format = $source_args['format'] ?? get_option( 'date_format' );

			return wp_date( $format, strtotime( $stamp ) );
		},
	) );
} );

And in the content or template:

<!-- wp:paragraph {"metadata":{"bindings":{"content":{
  "source":"wpfairs/last-reviewed","args":{"format":"j F Y"}
}}}} -->
<p>Not yet reviewed</p>
<!-- /wp:paragraph -->

Four details from the function reference that save a debugging session:

The source name needs a namespace. wpfairs/last-reviewed, not last-reviewed, and only lowercase alphanumerics, forward slashes and dashes. A name without a slash is rejected at registration, quietly, long before anything renders.
The callback gets three arguments, in order: the args array from the block’s binding, the WP_Block instance, and the name of the attribute being resolved. That third one is how a single source serves several attributes — branch on it to return a URL for url and a sentence for alt.
Return null and false mean different things. A string replaces the attribute value. null leaves the block’s own value in place, which is how you get a graceful fallback. false removes the attribute entirely — useful for dropping an empty rel, destructive if you do it to content.
PHP registration is read-only. get_value_callback renders a value; it cannot write one back. Editing from inside the block needs a matching registration in JavaScript with getValues, setValues and canUserEditValue, plus getFieldsList (WordPress 6.9 and later) if you want the source to appear in the Attributes dropdown rather than only working in hand-written markup.

If a binding renders nothing and you cannot tell why, the callback is ordinary PHP running at render time, so a log line inside it is the fastest diagnosis — enabling WordPress debugging and reading error logs safely covers doing that without printing notices to visitors. There is also a block_bindings_source_value filter, added in 6.7, if you need to adjust a value a source you do not own has returned.

Bindings, a field plugin, or a custom block?

Here is what I weigh before picking one on a client build. Disagree with the weighting rather than the conclusion — your editors and your deployment setup should move it.

What shape is the data? One string per post is bindings territory. A repeater of three testimonials, each with a name, a quote and a photo, is not — and no amount of cleverness makes it one.
Who fills the field in, and where do they expect to find it? A marketing team wants a labelled, validated field in a panel. Core gives you a meta key and the Custom Fields box. That gap is the honest reason field plugins are still worth paying for.
Who controls the output HTML? Bindings give you a core block’s markup, styled by the theme, and no say beyond that. If the design needs a specific wrapper, an SVG, or conditional markup, you need a block with a render_callback.
What survives the plugin being deactivated? Core bindings keep working as long as WordPress does. A third-party source stops resolving the moment its plugin is switched off, and the block falls back to whatever placeholder text was stored.
How much JavaScript are you prepared to maintain? A custom block is a build step, a dependency tree, and a deprecation every time you change its markup. A binding is a PHP callback. Over five years that difference is larger than it looks on day one.

Judged on those criteria, the three approaches land like this. Stacks into one card per approach on a phone.

 Core bindings + registered metaA field plugin’s binding sourceCustom block with render_callback
What you writeOne register_post_meta() callField definitions in the plugin’s UIblock.json, an edit component, a PHP render callback, a build step
Field UI for editorsThe Custom Fields box, or none — you build itLabelled, validated, conditional fields out of the boxWhatever you build in the inspector
Data shapesSingle stringsScalars only for bindings; richer types still need PHP. ACF explicitly excludes Repeater, Flexible Content, Gallery, Relationship and CheckboxAnything
Control over output HTMLNone beyond the core blockNone beyond the core blockTotal
Inside a Query LoopYes, via postId contextYes, same mechanismYes, if you read context
If the plugin is deactivatedNot applicable — it is coreBinding stops resolving; fallback text rendersBlock is unregistered; editor shows a missing-block notice
In version controlYes, it is a few lines of PHPField groups are in the database unless you export them to filesYes
Where it breaks downNo field UI, no repeaters, eight blocksYou are tied to the plugin’s release cycle and its supported field typesMaintenance. It is a JavaScript project now
Field plugin behaviour per the ACF, Meta Box and Pods documentation linked in Sources. The unsupported-type list is ACF’s own.

If you already run a field plugin, check whether it ships a source before writing your own. ACF added full support in 6.8.1 under the source name acf/field, taking the field name as args.key, and edits made in the block sync back to the field provided it has “Allow Access to Value in Editor UI” switched on. Meta Box registers four sources — meta-box/post-field, plus term, author and settings-page variants that need the matching extensions. Pods has pods/bindings-field, added in Pods 3.2. None of these replaces the core source; they sit alongside it, and a site can use several at once.

Where the Block Bindings API falls short

Worth knowing before you build a content model on it rather than after.

One value, one attribute, no logic. There is no conditional rendering, no iteration, and no way to hide the surrounding block when the field is empty. A bound Button with no URL is still a button. If you need the whole section to disappear, that is a dynamic block or a template part, not a binding.
Strings, not structures. Arrays, serialised values and repeaters have to be flattened to a string by your own source before a block can take them. Doing that inside get_value_callback works, but you have just written a templating layer in PHP, which is the thing you were trying to avoid.
Do not treat a bound value as a slot for markup. The point of binding is to supply a value to an attribute, not a chunk of HTML. If you find yourself trying to get a <ul> into a paragraph’s content, stop — you want a block, and you are one step away from storing unescaped user input in a place you have not audited.
Your callback runs on every render. Nothing caches it for you. A source that does a remote request, or a meta query across a thousand posts, will do that once per bound block per page view. Cache inside the callback with a transient if the lookup is not cheap, and measure before you ship it on an archive template.
Hand-written bindings are fragile to edit. The JSON lives in a block comment, and the editor’s response to malformed JSON is to flag the block, not the binding. Add bindings through the Attributes panel where you can, and keep template-file bindings short enough to read in one line.
There is no public opt-in for custom blocks. You cannot make your own block bindable the way core blocks are. The block_bindings_supported_attributes filter extends attributes on blocks that already support bindings; it does not add a block to the list.

So should you use it?

  • One or two scalar fields per post, shown in a fixed spot: yes, and do not install anything. Register the meta, bind the block, done in twenty minutes.
  • You already have ACF, Meta Box or Pods and a pile of existing fields: yes, through that plugin’s source. You keep the field UI your editors know and stop writing the_field() calls into templates.
  • A card layout repeated across a Query Loop: this is the strongest case on the page. One template, context-aware bindings, no per-post markup.
  • Repeaters, galleries, or anything that renders a list: no. Write a block with a render_callback and spend the effort there instead.
  • Data that lives in protected _-prefixed meta from another plugin: yes, but you are writing a custom source. Budget an hour, and check the plugin is not already shipping one.
  • You maintain a block library and want users to bind your blocks: not yet. There is no supported way in.

One habit worth adopting: keep every register_post_meta() and register_block_bindings_source() call in one small plugin file, with a comment above each saying which template or pattern consumes it. Bindings are invisible from the admin — there is no screen listing which blocks use which field — so that file is the only documentation anyone will have when a field needs renaming in two years.

Frequently asked questions

Which WordPress version do I need for block bindings?

WordPress 6.5 introduced the API. 6.7 added the editor-side registration functions and the block_bindings_source_value filter. 6.9 added the core/post-data and core/term-data sources, getFieldsList for the Attributes dropdown, and the block_bindings_supported_attributes filter. WordPress 7.1 added List Item support. On anything older than 6.5 the metadata.bindings JSON is simply ignored.

Why does my custom field not appear in the Attributes panel?

Four things to check, in this order: the meta is registered with show_in_rest => true; the key does not start with an underscore; it is registered as 'type' => 'string'; and it is registered for the post type you are editing. Fix those and it appears without any further work. If the key must stay protected, register your own source instead.

Can editors change the value from inside the bound block?

With the core/post-meta source, yes, for users with permission to edit that meta. For a custom source it depends entirely on how it was registered: a PHP-only source is read-only, and editing requires a JavaScript registration supplying setValues and canUserEditValue. ACF supports write-back for fields with “Allow Access to Value in Editor UI” enabled.

Do bindings work inside a Query Loop or an archive template?

Yes. The core/post-meta source declares uses_context of postId and postType, so each iteration of the loop resolves against its own post. Your own source gets the same treatment if you list the context keys you need in uses_context and read them from $block_instance->context.

Does the Block Bindings API replace ACF?

It replaces the display half, not the authoring half. Bindings remove the need for template PHP that echoes a field, which is a real saving. They do nothing about defining labelled, validated, conditional fields in an interface editors can use — which is what a field plugin is actually for. Most sites end up using both, with the plugin providing the fields and its binding source providing the display.

Can I bind a custom field to a block from a third-party block library?

No. Bindings are supported on a fixed list of core blocks, and there is no public way for a block to opt in. The block_bindings_supported_attributes filter adds attributes to blocks already on that list. If you need dynamic data in a third-party block, your options are that block’s own dynamic-content feature, if it has one, or replacing it with a core block you can bind.

Will bindings slow down my site?

The core post-meta source is a get_post_meta() call, which is already cached per post, so the cost is negligible. Your own source is as fast as you write it: it runs once per bound attribute per render, with no caching layer in front of it, so a remote request or an uncached query inside the callback is the thing to watch — particularly on an archive page rendering thirty cards.

Sources

Leave a Reply to This Post