A block that renders on the server and edits on the client

A block showing stock levels for a product, built the usual way: an edit component, a save function, markup written into the post. It was correct at the moment somebody pressed publish and wrong within the hour, and it stayed wrong until somebody opened the post and pressed update.

The symptom

what was in post_content:

  <!-- wp:turkerdev/stock {"sku":"ABC-1"} -->
  <div class="wp-block-turkerdev-stock">
    <span class="level">In stock: 41</span>
  </div>
  <!-- /wp:turkerdev/stock -->

the 41 was true on 2022-11-04. the page said 41 for
four months. actual stock on the day this was found: 0.

and a second failure: changing the markup in save()
produced "this block contains unexpected or invalid
content" on 1,400 posts.

The block validation warning is the second half of the same problem. A block that stores markup is a block whose markup is a schema, and changing it means every saved instance no longer matches what the code produces.

Why it happens

The default block model saves markup because most blocks are content, and content belongs in the post. A block whose output depends on data that lives elsewhere is not content — it is a query result, and there is nowhere sensible to put a query result in a post.

The fix

save returns null

registerBlockType( metadata, {
  edit: Edit,
  save: () => null,
} )

// post_content now contains only:
// <!-- wp:turkerdev/stock {"sku":"ABC-1"} /-->
//
// the attributes, and nothing else. block validation
// cannot fail, because there is no markup to compare.

The self-closing comment is the whole stored representation. That removes the validation problem permanently — which is a good enough reason to prefer dynamic rendering even for blocks whose data never changes.

block.json as the single registration

{
  "apiVersion": 3,
  "name": "turkerdev/stock",
  "title": "Stock level",
  "attributes": {
    "sku":       { "type": "string", "default": "" },
    "showLabel": { "type": "boolean", "default": true }
  },
  "supports": { "html": false, "reusable": false },
  "editorScript": "file:./index.js",
  "render": "file:./render.php"
}
add_action( 'init', function () {
    register_block_type( __DIR__ . '/build/stock' );
} );

// the render key in block.json means no render_callback
// argument is needed, and the file is loaded with
// $attributes, $content and $block already in scope.

The render key arrived in 6.1 and is worth using over a render_callback: the metadata file stays the only place the block is described, and the PHP is a template rather than a closure buried in a plugin bootstrap.

The render, and the escaping that is not optional

<?php
$level = turkerdev_stock_level( $attributes['sku'] );

if ( null === $level ) {
    return;   // render nothing rather than an error
}

$wrapper = get_block_wrapper_attributes( array(
    'class' => $level > 0 ? 'is-in-stock' : 'is-out-of-stock',
) );
?>
<div <?php echo $wrapper; // phpcs:ignore ?>>
    <span class="level"><?php echo esc_html( $level ); ?></span>
</div>

get_block_wrapper_attributes() is what makes the block honour the alignment, colour and spacing settings the editor offers — without it, every support declared in block.json renders a control that does nothing. It returns escaped output, which is the one place the phpcs ignore is legitimate.

The editor half, without duplicating the renderer

function Edit( { attributes } ) {
  const [ level, setLevel ] = useState( null )

  useEffect( () => {
    if ( ! attributes.sku ) return

    apiFetch( { path: `/turkerdev/v1/stock/${attributes.sku}` } )
      .then( ( r ) => setLevel( r.level ) )
  }, [ attributes.sku ] )

  return <div { ...useBlockProps() }>{ level ?? '—' }</div>
}

The editor calls the same data source through REST rather than reimplementing the lookup, which is the coupling worth accepting — the markup exists twice and the data does not. Keeping the editor preview visually approximate rather than pixel-identical is a deliberate decision that saves a great deal of duplicated CSS.

ServerSideRender, and when it is a trap

the alternative: <ServerSideRender block="..." />

what it does: an HTTP request per attribute change,
returning rendered markup.

when it is right:
  the render is complex, the block is rarely edited,
  and visual fidelity in the editor matters

when it is a trap:
  a text attribute. every keystroke is a request.
  debouncing helps and the block still feels laggy,
  and the editor cannot show a loading state that
  does not flicker.

we used it for one block with no text inputs.

Caching the render

$key   = 'td_stock_' . md5( $attributes['sku'] );
$level = get_transient( $key );

if ( false === $level ) {
    $level = turkerdev_stock_level( $attributes['sku'] );
    set_transient( $key, $level, 5 * MINUTE_IN_SECONDS );
}

// and the invalidation, from the place stock changes
add_action( 'turkerdev_stock_changed', function ( $sku ) {
    delete_transient( 'td_stock_' . md5( $sku ) );
} );

A dynamic block runs its query on every page render, which is the cost of correctness and is why the caching has to be part of the design rather than an afterthought. Five minutes with explicit invalidation gives a page that is right immediately after a change and cheap the rest of the time.

Verifying it worked

$ wp post get 8814 --field=content | grep -A2 turkerdev/stock
<!-- wp:turkerdev/stock {"sku":"ABC-1"} /-->

$ wp eval 'turkerdev_set_stock("ABC-1", 7);'
$ curl -s https://example.test/product/abc-1 | grep 'class="level"'
<span class="level">7</span>

# queries added per page render
$ wp profile stage --url=/product/abc-1 | grep -i cache
  cache_hits 41   cache_misses 1

# and the validation warning, on 1,400 posts
$ wp post list --post_type=post --format=count
1400
# zero "unexpected or invalid content" notices

The single cache miss on a cold render, and none afterwards, is the assertion that the transient is doing its job. The absence of validation warnings across fourteen hundred posts is the other outcome, and it is the one that removed a recurring support conversation.

What this costs

Two renderers that must agree, in two languages, maintained by whoever touches the block next. The editor preview and the front end drift, and the drift is invisible until somebody compares them side by side — which is why the preview is deliberately approximate rather than aiming for fidelity it cannot keep.

The other cost is that the block now depends on a REST route and a caching layer, both of which can fail in ways a static block could not. The return on a null stock level is the whole error handling, and rendering nothing is a decision that will look wrong to somebody expecting a message.