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>
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 blocks
Hydra sets your developers free
Read MoreYou 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: