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.
Editing app-level references
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. ve.edit({ id, type }) makes any element open that document in the studio:
{#each posts as post}
<a href="/blog/{post.slug}" {...ve.edit({ id: post.id, type: 'blog_post' })}>
{post.title}
</a>
{/each}Spread the returned attributes onto any element; clicking it in preview opens that post's editor (as a modal, keeping the page you were editing underneath). Outside preview it returns {}, so it's inert on the live site. Unlike ve.encode (which navigates to a field in the current document), ve.edit targets a different document entirely — no stored reference required.
API reference
| Export | What it is |
|---|---|
AphexVisualOverlay | Component — wrap your site once. Sets up the overlay + live-document context. |
usePreview() | Returns { inPreview, document, live, encode, edit, image }. Call once per component. |
ve.edit({ id, type }) | Attributes to make any element open a different document (app-level references). |
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