Hydra works with any frontend, including ones that have no client-side reactivity at all — pure server-rendered frameworks like Astro, PHP, Django, Rails, Laravel, Symfony, Go html/template. The bridge ships a built-in pattern for these: one config option on initBridge and one small endpoint on your server.
If you're using React, Vue, Svelte, Solid, Next, Nuxt, or any framework with client-side reactivity, you don't need this. The bridge fires FORM_DATA, your framework reconciles the DOM, and only the changed nodes update. Contenteditable cursors, image loads, and scroll positions survive every edit "for free" because the virtual DOM diff doesn't touch unchanged nodes.
Server-rendered-only frameworks have no such reconciliation. If you naively swapped the whole content area's innerHTML on every FORM_DATA, every keystroke would destroy contenteditable cursors, reload images, jump scroll, and reset IME state. The editing experience would be visibly broken.
The fix is to update only the smallest block that changed, and let the rest of the DOM stay untouched. That's what the bridge does when you set renderEndpoint.
hydra.js)findChangedUnit(prevFormData, newFormData) walks the new form data against the previous one looking for the shallowest changed subtree. At each container level:
items array differs (add/remove/reorder) → this container is the unititems unchanged → recurse into that childSpans more than one nesting level (e.g. a block moved from one column to another) → falls back to { unit: 'page' }. Most edits stay at one level because one focused field = one block.
data-block-uid contractFor the bridge to swap [data-block-uid=X].outerHTML reliably, every block's outermost rendered element must carry data-block-uid={id}. This is Astro-only / server-only — reactive frontends don't care because their reconciliation finds DOM nodes via virtual DOM, not query selectors.
The recommended pattern: write a BlockRenderer (or equivalent) wrapper in your templating language that puts the <div data-block-uid={id}> around every block before dispatching to the block's own template. Then block authors don't think about it — the wrapper IS the contract.
That dispatch must also handle @type: "empty" — the placeholder Hydra seeds into any container region with no defaultBlockType and more than one allowedBlocks — by rendering an empty, selectable slot (with its data-block-uid) rather than erroring. See Empty Blocks.
The full working example lives at `docs/examples/test-astro/` with block components in `docs/examples/examples/astro/`.
The HTML page that loads in the editor iframe just needs to pull in the bridge and call initBridge with the endpoint:
The recipe is the same in every framework — only the rendering call changes:
Framework | Render call |
|---|---|
Astro |
|
PHP |
|
Django |
|
Rails |
|
Laravel |
|
Symfony (Twig) |
|
Go templates |
|
Everything else — the diff, the POST, the swap, the data-block-uid contract — is identical because the bridge handles it.
approach) but slower than client-side reconciliation. For a typical edit (one block at a time) it's a few hundred bytes and a few milliseconds on a same-origin endpoint. Don't put the endpoint behind authentication that adds another round trip.
block from outside the renderer (e.g. a CSS-grid <li> your layout adds) will break outerHTML swaps — the swap would replace the wrapper too. Always wrap inside the renderer.
CORS-enabled). The bridge POSTs from the iframe child to whatever URL you give it; cross-origin without CORS will fail.
it to disk or replay it — it's editing state, potentially containing unpublished content.