Inka makes an existing frontend editable. You add a small script and a few HTML attributes, and editors compose pages inside the bounds your design system allows — no React or Vue required in the frontend itself. This guide takes you from a running page to custom blocks, live preview, and templates.
Quick start
Add Inka to a page in your framework. Each tab is a real, working starter — the same source the test suite runs against.
<!-- pages/[...slug].vue --><template><divv-for="id in page?.blocks_layout?.items":key="id":data-block-uid="editing ? id : undefined"><!-- The card block is rendered editable; anything else is shown as raw JSON. --><av-if="page.blocks[id]['@type'] === 'card'":href="page.blocks[id].link":data-edit-link="editing ? 'link' : undefined"><!-- data-edit-media: click to pick/upload image · data-edit-text: edit text in place --><img:src="page.blocks[id].image":data-edit-media="editing ? 'image' : undefined" /><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><prev-else>{{ JSON.stringify(page.blocks[id], null, 2) }}</pre></div></template><scriptsetup>import { ref, onMounted } from'vue'import { initBridge } from'@hydra-js/hydra.js'const page = ref(null)
const editing = ref(false)
onMounted(async () => {
// Only init the bridge when loaded inside the editor.if (window.name.startsWith('hydra')) {
editing.value = trueinitBridge({
// Declare the one block type we render richly.blocks: { card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
} } } },
// Re-render on every edit.onEditChange: (data) => { page.value = data },
})
} else {
// On the live site: fetch this page and render once.
page.value = await (awaitfetch(`/++api++${useRoute().path}`)).json()
}
})
</script>
// app/[...slug]/page.jsx'use client'import { useState, useEffect } from'react'import { initBridge } from'@hydra-js/hydra.js'exportdefaultfunctionPage({ params }) {
const [page, setPage] = useState(null)
const [editing, setEditing] = useState(false)
useEffect(() => {
// Only init the bridge when loaded inside the editor.if (window.name.startsWith('hydra')) {
setEditing(true)
initBridge({
// Declare the one block type we render richly.blocks: {
card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
} } },
},
// Re-render on every edit.onEditChange: setPage,
})
} else {
// On the live site: fetch this page and render once.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]
// The card block is rendered editable; anything else is shown as raw JSON.if (block['@type'] !== 'card')
return (
<prekey={id}data-block-uid={editing ? id:undefined}>
{JSON.stringify(block, null, 2)}
</pre>
)
return (
<divkey={id}data-block-uid={editing ? id:undefined}><ahref={block.link}data-edit-link={editing ? 'link' :undefined}><imgsrc={block.image}data-edit-media={editing ? 'image' :undefined} /><h3data-edit-text={editing ? 'title' :undefined}>{block.title}</h3><pdata-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/hydra.js'let page = $state(null)
let editing = $state(false)
onMount(async () => {
// Only init the bridge when loaded inside the editor.if (window.name.startsWith('hydra')) {
editing = trueinitBridge({
// Declare the one block type we render richly.blocks: { card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
} } } },
// Re-render on every edit.onEditChange: (data) => { page = data },
})
} else {
// On the live site: fetch this page and render once.const res = awaitfetch(`/++api++${window.location.pathname}`)
page = await res.json()
}
})
</script>
{#if page}
{#each page.blocks_layout?.items ?? [] as id}
{#if page.blocks[id]['@type'] === 'card'}
<!-- The card block is rendered editable. --><divdata-block-uid={editing ? id:undefined}><ahref={page.blocks[id].link}data-edit-link={editing ? 'link' :undefined}><imgsrc={page.blocks[id].image}data-edit-media={editing ? 'image' :undefined} /><h3data-edit-text={editing ? 'title' :undefined}>{page.blocks[id].title}</h3><pdata-edit-text={editing ? 'description' :undefined}>{page.blocks[id].description}</p></a></div>
{:else}
<!-- Everything else is shown as raw JSON. --><predata-block-uid={editing ? id:undefined}>{JSON.stringify(page.blocks[id], null, 2)}</pre>
{/if}
{/each}
{/if}
<!-- index.html --><divid="content"></div><scripttype="module">import { initBridge } from'@hydra-js/hydra.js'const editing = window.name.startsWith('hydra')
if (editing) {
// In the editor: declare the one block we render richly, and re-render on every edit.initBridge({
blocks: { card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
} } } },
onEditChange: render,
})
} else {
// On the live site: fetch this page and render once.render(await (awaitfetch(`/++api++${location.pathname}`)).json())
}
functionrender(page) {
document.getElementById('content').innerHTML =
page.blocks_layout.items.map((id) => {
const block = page.blocks[id]
const uid = editing ? ` data-block-uid="${id}"` : ''// The card block is rendered editable; anything else is shown as raw JSON.if (block['@type'] !== 'card')
return`<pre${uid}>${JSON.stringify(block, null, 2)}</pre>`return`
<div${uid}>
<a href="${block.link}"${editing ? ' data-edit-link="link"' : ''}>
<img src="${block.image}"${editing ? ' data-edit-media="image"' : ''} />
<h3${editing ? ' data-edit-text="title"' : ''}>${block.title}</h3>
<p${editing ? ' data-edit-text="description"' : ''}>${block.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. The same
// pattern works for PHP, Django, Rails, Laravel: see server-rendered-frontends.md
---
<!DOCTYPE html><html><body><divid="content"></div><script>import { initBridge } from'@hydra-js/hydra.js'if (window.name.startsWith('hydra')) {
initBridge({
// Declare the one block type we render richly.blocks: { card: { blockSchema: { properties: {
image: { widget: 'image' },
title: { type: 'string' },
description: { type: 'string' },
link: { widget: 'url' },
} } } },
// Server-render mode: 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.tsimport { experimental_AstroContainer asAstroContainer } from'astro/container'importBlockRendererfrom'../../components/BlockRenderer.astro'exportconstPOST = async ({ request }) => {
const { unit, formData } = await request.json()
const container = awaitAstroContainer.create()
// BlockRenderer.astro emits data-block-uid + data-edit-* attributes — the same// DOM contract as the other tabs, just produced server-side.const html = await container.renderToString(BlockRenderer, { props: { unit, formData } })
returnnewResponse(html, { headers: { 'Content-Type': 'text/html' } })
}
To make your site editable with Inka you load hydra.js in your frontend and call initBridge(). This sets up a two-way communication channel that handles authentication, page navigation, and live content updates.