Visual Editing
Live preview + click-to-edit. Wrap your site once, add a previewUrl, and edits stream into a side-by-side preview where every field is clickable.
Work in progress. Visual editing is still under active development — the API (usePreview,
ve.image, setPortableTextField, the previewUrl schema option) may change in a future
release, and you may hit rough edges. Use it, but pin your version and expect breaking changes.
Visual editing gives editors a side-by-side live preview of the page they're editing, where clicking any piece of content jumps to that field in the editor. Type in the editor → the preview updates instantly. Click a heading in the preview → the editor focuses that heading.
It runs entirely inside your SvelteKit app — the preview is just your real public route in an iframe, driven by postMessage. No separate preview server, no draft API.
How it works (30 seconds)
- A schema with a
previewUrlgets a split view in the editor: fields on the left, your page in an iframe on the right. - The editor pushes the live document to the iframe on every keystroke (debounced).
- The document's string fields are stega-encoded — invisible markers are woven into the text carrying
{ field, … }navigation data. - An overlay in the preview reads those markers, draws a hover outline, and on click posts the field path back to the editor, which focuses it.
You wire up two things: wrap your site in <AphexVisualOverlay>, and add a previewUrl to the schema. Everything else is the usePreview() helper.
1. Wrap your site
Add the overlay once, in your public layout. It's a no-op unless the URL carries the ?aphex-preview marker (which the editor's iframe adds), so it has zero effect on real visitors.
<script lang="ts">
import { AphexVisualOverlay } from '@aphexcms/visual-editing';
let { children } = $props();
</script>
<AphexVisualOverlay>
{@render children()}
</AphexVisualOverlay>2. Add a previewUrl to the schema
previewUrl maps a document to the public route that renders it. It receives the document (and the organizationId for multi-tenant setups) and returns a path or absolute URL.
export const blogPost = defineType({
name: 'blog_post',
type: 'document',
// ...
previewUrl: (doc) => (doc.slug ? `/blog/${doc.slug}?aphex-preview=1` : null)
});That's the entire setup. Plain text fields are now click-to-edit automatically — the CMS encodes them for you. The rest of this guide is about the values the CMS can't see.
3. usePreview() — the one helper
Everything you need on a page comes from a single call:
<script lang="ts">
import { usePreview, stegaClean } from '@aphexcms/visual-editing';
import type { BlogPost } from '$lib/generated-types';
let { data } = $props();
const ve = usePreview();
// Live document (while previewing) merged over the server fallback.
const post = $derived(ve.live<BlogPost>(data.post));
const cover = $derived(ve.image(post.coverImage));
const coverAlt = $derived(cover.alt || stegaClean(post.title ?? ''));
</script>
<h1>{post.title}</h1>
<img src={cover.src} alt={ve.encode(coverAlt, { field: 'coverImage' })} />ve.live(fallback)
Returns the live document the editor is pushing, or your server-loaded fallback when not previewing. Always use it to source the data you render — that's what makes the preview update as you type.
const post = $derived(ve.live<BlogPost>(data.post));ve.encode(value, payload)
The CMS auto-encodes a document's own string fields, so {post.title} is already clickable. But some rendered values aren't literally in the document and have nothing to encode:
- a resolved reference label (a tag's title, an author's name — you looked it up from an id)
- a value rendered into an attribute (a date in
datetime, alt text on an<img>)
ve.encode stamps those at render time. In preview it returns the value with the marker woven in; outside preview it returns the value untouched — no ternary needed.
<time datetime={ve.encode(post.postDate, { field: 'postDate' })}>{formatted}</time>
{#each tags as tag, i}
<a href="/tag/{tag.slug}">{ve.encode(tag.title, { field: 'tags', arrayIndex: i })}</a>
{/each}The payload tells the editor what to focus:
| Field | Meaning |
|---|---|
field | top-level document field name (required, except inline — see below) |
arrayIndex | index into an array field |
blockIndex / blockKey | a block within a Portable Text body |
objectPath | dotted path to a nested object subfield |
ve.image(img)
Resolve an image field to { src, alt } in one call — destructure it:
const { src, alt } = $derived(ve.image(post.coverImage));Both read image.asset.url / image.asset.alt, which are populated before render — on the server by injectAssetUrls (see below), and in preview by the editor injecting them into the pushed document. So the same call works for SSR and for a newly-added or swapped image in preview (rendered immediately, before it's even saved). alt is the effective alt: a per-placement override if set, otherwise the asset's default. src is null when the image is unset.
Hydrating images on the server
Aphex asset URLs aren't derivable from the _ref alone, so a document loaded from the API has no URLs on its image fields yet. Call assetService.injectAssetUrls in your load to resolve every image in one batch and inject asset.url/asset.alt in place — then the page reads them with ve.image, with no side-channel data to thread through components.
Here's a complete public-route load using only the engine's server APIs (locals.aphexCMS is the services bundle injected by the CMS hook):
import { error } from '@sveltejs/kit';
import { systemContext } from '@aphexcms/cms-core/local-api/auth-helpers';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
const cms = locals.aphexCMS;
// A public site renders one organization's content. Single-org deploy → the only org;
// multi-tenant → resolve it from the request host. A public visitor has no `locals.auth`,
// so use `systemContext` (a system read) rather than `authToContext` (which requires a session).
const [org] = await cms.databaseAdapter.findAllOrganizations();
if (!org) error(404, 'No organization configured');
// The CMS hook resolves the read perspective once per request onto
// `locals.previewPerspective` ('draft' only for an authenticated ?aphex-preview
// session, else 'published'). Bake it into the context and every query inherits it —
// no per-load wiring. (See "Drafts and perspective" below.)
const context = {
...systemContext(org.id),
perspective: locals.previewPerspective ?? 'published'
};
const { docs } = await cms.localAPI.collections.blog_post.find(context, {
where: { slug: { equals: params.slug } },
limit: 1
});
const post = docs[0];
if (!post) error(404, 'Post not found');
// Walks the whole document — cover, inline images, SEO image — no field list to maintain.
await cms.assetService.injectAssetUrls(org.id, post);
return { post };
};injectAssetUrls is variadic, so hydrate several documents (a post plus its resolved author, a list of cards) in a single de-duped batch: cms.assetService.injectAssetUrls(org.id, author, ...posts).
This is the same walk the editor runs client-side on the live document, so SSR and preview produce
documents of the identical shape — image.asset.url is always there. That symmetry is why
ve.image needs nothing but the image value.
Inline images in Portable Text
A Portable Text body renders custom blocks (like images) through your own components, which don't know which document field they belong to. Declare it once where you render the body:
<script lang="ts">
import { setPortableTextField } from '@aphexcms/visual-editing';
let { value, field = 'content' } = $props();
setPortableTextField(() => field);
</script>Then inline blocks call ve.encode without a field — it's filled in from that context — and just add their blockKey:
<script lang="ts">
import { usePreview } from '@aphexcms/visual-editing';
let { portableText } = $props();
const ve = usePreview();
const { src, alt } = $derived(ve.image(portableText.value));
</script>
<img {src} alt={ve.encode(alt, { blockKey: portableText.value._key })} loading="lazy" />stegaClean(value)
Markers are invisible spaces, but they don't belong everywhere. Strip them with stegaClean when a value goes somewhere it shouldn't carry navigation data:
<svelte:head>—<title>, meta tags- a value reused as a fallback for an encoded field (e.g. a title used as alt text — see
coverAltabove) - a value used as a CSS value or attribute keyword — e.g.
style="text-align: {align}"or aclassname. Markers make the value invalid, so the browser silently drops it. - a value used as a lookup / map key or a discriminant —
themes[variant],languageLoaders[lang],variant === 'warning'. The markers make it miss silently. - string comparisons
<svelte:head><title>{stegaClean(post.title)}</title></svelte:head>This is how you drive styles from a field. A number field (e.g. a slider) passes straight
through — style="padding: {page.containerPadding}px". A string field (e.g. an alignment
picker) must be cleaned first — style="text-align: {stegaClean(page.headerAlign)}" — or the live
preview's markers break the CSS. Either way, source the value through ve.live(...) so it updates
as you edit.
Custom Portable Text blocks are the sneaky one. A block's text renders fine (the markers
are invisible), so preview looks like it works — but any block field you use as a class name,
a lookup/map key, or a === 'x' discriminant silently fails, because the marked value never
matches. The tell: a block's text updates live, but its variant/styling doesn't. Clean those:
// `tone` drives a CSS class AND equality checks → clean it
const tone = $derived(stegaClean(portableText.value.tone ?? 'info'));
// <aside class="callout callout--{tone}"> · tone === 'warning'Fields you only display (the callout body, a heading) don't need it — only fields consumed as classes, keys, or comparisons.
Drafts and perspective
Appending ?aphex-preview=1 only turns on the overlay — it never exposes drafts on its own. Draft visibility is gated on an authenticated editor session, so a logged-out visitor hitting ?aphex-preview=1 still only sees published content.
You don't resolve this per load. The CMS hook decides it once per request and stores it on event.locals.previewPerspective ('draft' only for an authenticated ?aphex-preview session, else 'published'). Bake that into the context your loads share, and every query inherits it:
import { systemContext } from '@aphexcms/cms-core/local-api/auth-helpers';
export function siteContext(locals: App.Locals) {
// ...resolve the org...
const perspective = locals.previewPerspective ?? 'published';
return { context: { ...systemContext(org.id), perspective } };
}A load then just queries — published on the live site, draft in preview — with nothing to thread through:
const { context } = siteContext(locals);
const { docs } = await cms.localAPI.collections.blog_post.find(context, { limit: 50 });The context's perspective is the default; an individual call can still override it (find(context, { perspective: 'published' })) — a sitemap, say, should stay published regardless.
Overriding the policy
The default lives in getPreviewPerspective(auth, url). To change the signal — a preview token, a different query param, per-role rules — provide preview.resolvePerspective in your config; the hook runs it instead:
export default createCMSConfig({
preview: {
resolvePerspective: ({ auth, url }) => (canPreview(auth) ? 'draft' : 'published')
}
});getPreviewPerspective(locals.auth, url) is still exported if you'd rather resolve it inline in a one-off load.
ve.edit(target) — click-to-edit for content stega can't mark up
The CMS auto-encodes a document's own string fields, so most text is already clickable. ve.edit covers what that can't reach. Spread the returned attributes onto any element; outside preview it returns {}, so it's inert on the live site.
It has two targets, chosen by which keys you pass.
Another document — { id, type }
Sometimes a page renders a document it isn't "about" — a "latest posts" block on the home page, a related-articles list — where the items come from an app-level query, not a stored reference on the current document. Those cards have no field to encode, but they are real documents with real ids:
{#each posts as post}
<a href="/blog/{post.slug}" {...ve.edit({ id: post.id, type: 'blog_post' })}>
{post.title}
</a>
{/each}Clicking opens that post's editor as a modal, keeping the page you were editing underneath.
A field of the open document — { field, arrayIndex }
Clicking reveals that field in the form pane, and with arrayIndex, the specific row. Use it for a list whose entries are references: revealing the row is what lets an author reorder or remove it, which opening the referenced document does not.
{#each menu.items as item, i}
<li {...ve.edit({ field: 'items', arrayIndex: i })}>{item.name}</li>
{/each}Choosing between them with ve.documentType
When one page is reachable from several document types, the right target depends on which one the author has open. ve.documentType is the schema type currently being edited, or null outside preview:
{#each menu.items as item, i}
{@const attrs =
ve.documentType === 'menu'
? ve.edit({ field: 'items', arrayIndex: i })
: ve.edit({ id: item.id, type: 'menuItem', field: 'name' })}
<li {...attrs}>{item.name}</li>
{/each}Editing the menu reveals the dish's slot in the list; editing a dish — whose preview URL is also this page — opens that dish instead.
The hover overlay appends [n] to the label for a row, so several rows of one list don't all
read as the same bare field name.
API reference
| Export | What it is |
|---|---|
AphexVisualOverlay | Component — wrap your site once. Sets up the overlay + live-document context. |
usePreview() | Returns { inPreview, document, documentType, live, encode, edit, image }. Call once per component. |
ve.documentType | Schema type currently open in the editor, or null outside preview. |
ve.edit({ id, type }) | Attributes to make any element open a different document (app-level references). |
ve.edit({ field, arrayIndex }) | Attributes to reveal a field — or one row of it — of the document being edited. |
setPortableTextField(field) | Declare the field a Portable Text body belongs to, for inline blocks. |
stegaClean(value) | Strip stega markers from a string or JSON tree. |
isPreviewMode(url) | Whether the URL has the ?aphex-preview marker. |
stegaEncode / stegaDecode | Low-level marker encode/decode (you rarely need these — use ve.encode). |
Last updated on