A visual headless CMS using Plone — drag-and-drop blocks and inline text editing, with whichever frontend stack you choose.
Most CMS platforms force a trade-off: visual editing or frontend freedom. Hydra gives you both.
Visual Editing
True WYSIWYG with drag-and-drop blocks. No special frontend framework required — just HTML attributes.
Any Frontend
Use React, Vue, Nuxt, Next.js, or any stack. Switch between frontends mid-edit for omni-channel delivery.
Truly Decoupled
Your frontend is independent code you own. Upgrade the CMS without rewriting your site; switch frameworks and get the same editing experience.
Open Source
Secure, scalable Plone backend. Host anywhere, control your costs and security.
<!-- pages/[...slug].vue -->
<template>
<!-- data-block-uid: makes block selectable, draggable, and editable -->
<div v-for="id in page?.blocks_layout?.items" :key="id"
:data-block-uid="editing ? id : undefined">
<!-- data-edit-link: click to edit link URL in sidebar -->
<a :href="page.blocks[id].link"
:data-edit-link="editing ? 'link' : undefined">
<!-- data-edit-media: click to pick/upload image in sidebar -->
<img :src="page.blocks[id].image"
:data-edit-media="editing ? 'image' : undefined" />
<!-- data-edit-text: edit text directly in the preview -->
<h3 :data-edit-text="editing ? 'title' : undefined">
{{ page.blocks[id].title }}
</h3>
<p :data-edit-text="editing ? 'description' : undefined">
{{ page.blocks[id].description }}
</p>
</a>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { initBridge } from 'hydra-js'
const page = ref(null)
const editing = ref(false)
onMounted(async () => {
// Only init bridge when loaded inside the editor
if (window.name.startsWith('hydra')) {
editing.value = true
initBridge({
// Register custom block types with their field schemas
blocks: {
card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
}}}
},
// Receive live updates as editor changes content
onEditChange: (data) => { page.value = data }
})
} else {
const res = await fetch(`/++api++${useRoute().path}`)
page.value = await res.json()
}
})
</script>// app/[...slug]/page.jsx
'use client'
import { useState, useEffect } from 'react'
import { initBridge } from 'hydra-js'
export default function Page({ params }) {
const [page, setPage] = useState(null)
const [editing, setEditing] = useState(false)
useEffect(() => {
// Only init bridge when loaded inside the editor
if (window.name.startsWith('hydra')) {
setEditing(true)
initBridge({
// Register custom block types with their field schemas
blocks: {
card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
}}}
},
// Receive live updates as editor changes content
onEditChange: setPage
})
} else {
fetch(`/++api++/${params.slug?.join('/') || ''}`)
.then(r => r.json()).then(setPage)
}
}, [])
if (!page) return <div>Loading...</div>
return page.blocks_layout?.items?.map(id => {
const block = page.blocks[id]
return (
// data-block-uid: makes block selectable, draggable, and editable
<div key={id} data-block-uid={editing ? id : undefined}>
{/* data-edit-link: click to edit link URL in sidebar */}
<a href={block.link}
data-edit-link={editing ? 'link' : undefined}>
{/* data-edit-media: click to pick/upload image in sidebar */}
<img src={block.image}
data-edit-media={editing ? 'image' : undefined} />
{/* data-edit-text: edit text directly in the preview */}
<h3 data-edit-text={editing ? 'title' : undefined}>
{block.title}
</h3>
<p data-edit-text={editing ? 'description' : undefined}>
{block.description}
</p>
</a>
</div>
)
})
}<!-- src/routes/[...slug]/+page.svelte -->
<script>
import { onMount } from 'svelte'
import { initBridge } from 'hydra-js'
let page = $state(null)
let editing = $state(false)
onMount(async () => {
// Only init bridge when loaded inside the editor
if (window.name.startsWith('hydra')) {
editing = true
initBridge({
// Register custom block types with their field schemas
blocks: {
card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
}}}
},
// Receive live updates as editor changes content
onEditChange: (data) => { page = data }
})
} else {
const res = await fetch(`/++api++${window.location.pathname}`)
page = await res.json()
}
})
</script>
{#if page}
{#each page.blocks_layout?.items ?? [] as id}
<!-- data-block-uid: makes block selectable, draggable, and editable -->
<div data-block-uid={editing ? id : undefined}>
<!-- data-edit-link: click to edit link URL in sidebar -->
<a href={page.blocks[id].link}
data-edit-link={editing ? 'link' : undefined}>
<!-- data-edit-media: click to pick/upload image in sidebar -->
<img src={page.blocks[id].image}
data-edit-media={editing ? 'image' : undefined} />
<!-- data-edit-text: edit text directly in the preview -->
<h3 data-edit-text={editing ? 'title' : undefined}>
{page.blocks[id].title}
</h3>
<p data-edit-text={editing ? 'description' : undefined}>
{page.blocks[id].description}
</p>
</a>
</div>
{/each}
{/if}<!-- index.html -->
<div id="content"></div>
<script type="module">
import { initBridge } from 'hydra-js'
let editing = false
// Only init bridge when loaded inside the editor
if (window.name.startsWith('hydra')) {
editing = true
initBridge({
// Register custom block types with their field schemas
blocks: {
card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
}}}
},
// Receive live updates as editor changes content
onEditChange: renderPage
})
} else {
const res = await fetch(`/++api++${location.pathname}`)
renderPage(await res.json())
}
function renderPage(page) {
const el = document.getElementById('content')
el.innerHTML = page.blocks_layout.items.map(id => {
const b = page.blocks[id]
return `
<!-- data-block-uid: makes block selectable, draggable, and editable -->
<div ${editing ? `data-block-uid="${id}"` : ''}>
<!-- data-edit-link: click to edit link URL in sidebar -->
<a href="${b.link}"
${editing ? 'data-edit-link="link"' : ''}>
<!-- data-edit-media: click to pick/upload image in sidebar -->
<img src="${b.image}"
${editing ? 'data-edit-media="image"' : ''} />
<!-- data-edit-text: edit text directly in the preview -->
<h3 ${editing ? 'data-edit-text="title"' : ''}>
${b.title}
</h3>
<p ${editing ? 'data-edit-text="description"' : ''}>
${b.description}
</p>
</a>
</div>`
}).join('')
}
</script><!-- src/pages/[...slug].astro
First paint and every subsequent render come from /api/render —
blocks are rendered server-side by .astro components via Astro's
Container API. Same pattern works for PHP, Django, Rails, Laravel:
see docs/server-rendered-frontends.md -->
<!DOCTYPE html>
<html>
<body>
<div id="content"></div>
<script>
import { initBridge } from 'hydra-js'
if (window.name.startsWith('hydra')) {
initBridge({
// Register custom block types with their field schemas
blocks: {
card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
}}}
},
// Server-render mode (Storyblok-style): the bridge POSTs each
// smallest-changed-unit to renderEndpoint and swaps the
// returned HTML into renderContainer.
renderEndpoint: '/api/render',
renderContainer: '#content',
})
}
</script>
</body>
</html>
// src/pages/api/render.ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
import BlockRenderer from '../../components/BlockRenderer.astro'
export const POST = async ({ request }) => {
const { unit, formData } = await request.json()
const container = await AstroContainer.create()
// BlockRenderer.astro emits data-block-uid + data-edit-* attributes —
// same DOM contract as the other tabs, just produced server-side.
const html = await container.renderToString(BlockRenderer, {
props: { unit, formData },
})
return new Response(html, { headers: { 'Content-Type': 'text/html' } })
}
You can log in and experience currently working features (Volto like but on any frontend)
See all Content Types
Frontend freedom makes it easy to create beautiful and fast experiences
See all blocksYou can use this site to test Hydra Edit.
Disclaimer: This instance is reset every night, so all changes will be lost afterwards.
You can log in and use it as an admin user using these credentials: username: admin password: admin
This site uses some recommended add-ons: