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 { getPreviewPerspective } from '@aphexcms/cms-core/server';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params, url }) => {
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');
const context = systemContext(org.id);
// 'draft' only when ?aphex-preview is present AND the request carries a valid editor session;
// otherwise 'published'. (See "Drafts and perspective" below.)
const perspective = getPreviewPerspective(locals.auth, url);
const { docs } = await cms.localAPI.collections.blog_post.find(context, {
where: { slug: { equals: params.slug } },
perspective,
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. - 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.
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. Resolve the real perspective in your load function:
import { getPreviewPerspective } from '@aphexcms/cms-core/server';
export async function load({ locals, url }) {
const perspective = getPreviewPerspective(locals.auth, url); // 'draft' | 'published'
// ...fetch with that perspective
}So a logged-out visitor hitting ?aphex-preview=1 still only sees published content.
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, image }. Call once per component. |
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