Getting started
Your presentation layer is the editor
If you've built a site on a headless CMS, you know the failure mode. The CMS generates its editing UI from your data model, so a page becomes a wall of stacked form fields. Nest a few blocks — a section containing columns containing cards containing rich text — and editing turns into an archaeology dig: endless vertical scrolling through collapsed panels that look nothing like the page they produce.
You have two bad options from there:
Live with the generated forms. Editors lose all sense of what they're building. "Which of these five identical Section panels is the one above the fold?" becomes a daily question.
Customize the admin. Now you maintain two frontends — the real one and the editing one — plus a translation layer that maps admin widgets to rendered output. Every new block means writing it twice and keeping the two in sync forever.
Uncial removes the fork. You write a block once, as an ordinary frontend component — the same component your site renders in production. Uncial mounts that component inside the editor as a live, editable block, and mounts it again (SSR-capable) in your frontend. The editor is not a form that approximates the page; it is the page, with editing chrome around it.
When the editing surface renders the same components with the same document JSON as production, preview isn't a feature you bolt on with draft APIs and iframe round-trips — it's just what the editor looks like. What the editor sees is what the reader gets, because it's literally the same code. Concretely, one block definition gives you:
a WYSIWYG editing experience where nested content looks nested, not stacked,
an SSR-capable renderer for your public frontend,
typed attribute editing UI derived from the component's props,
and a plain ProseMirror-compatible JSON document your backend can store anywhere — including, via uncial-wagtail, a regular Wagtail page field.
Add Uncial to a Svelte 5 app
Install the package in your application. Uncial expects svelte@^5 as a peer dependency.
npm install uncial
pnpm add uncial
bun add uncialDefine a custom block once
This is the core move: a block starts life as a plain Svelte component — no editor imports, no CMS awareness. defineSvelteBlock then wraps it with a stable id, an editor label, and typed attribute defaults. That one definition powers both the WYSIWYG editor and the SSR-capable renderer, so there is no second "admin widget" to write and no translation layer to keep in sync.
<!-- src/lib/blocks/PromoCard.svelte -->
<script lang="ts">
interface Props {
title?: string;
body?: string;
}
let { title = 'Spring launch', body = 'Save 20% on featured plans.' }: Props = $props();
</script>
<article class="promo-card">
<h3>{title}</h3>
<p>{body}</p>
</article>// src/lib/blocks/promoCard.ts
import { defineSvelteBlock } from 'uncial';
import PromoCard from './PromoCard.svelte';
export const promoCard = defineSvelteBlock({
id: 'promoCard',
label: 'Promo Card',
description: 'A reusable promotional card block',
attributes: {
title: '',
body: { default: '', input: 'textarea' }
},
component: PromoCard
});Create the registry, schema, and controller
The registry is the shared block catalog. The schema controls allowed blocks and marks. The attributes controller powers block and link editing UI.
// src/lib/uncial.ts
import { createBlockAttributesController, createBlockRegistry, createSchema } from 'uncial';
import { promoCard } from './blocks/promoCard';
export const blocks = createBlockRegistry([promoCard]);
export const schema = createSchema(blocks);
export const attributesController = createBlockAttributesController();Edit and render the same JSON
Editor and renderer share one document and one block registry. Bind your document to Editor with bind:json; pass the saved document to Renderer as content with the same blocks and schema. Because both sides render your actual components, the editor doubles as a live preview — there's no separate preview pipeline to build.
<!-- src/routes/editor/+page.svelte -->
<script lang="ts">
import 'uncial/styles';
import { Editor, Renderer } from 'uncial';
import { attributesController, blocks, schema } from './uncial';
let document = $state({
type: 'doc',
content: [{ type: 'paragraph' }]
});
</script>
<Editor {blocks} {schema} {attributesController} bind:json={document} />
<Renderer content={document} {blocks} {schema} />