Aphex

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)

  1. A schema with a previewUrl gets a split view in the editor: fields on the left, your page in an iframe on the right.
  2. The editor pushes the live document to the iframe on every keystroke (debounced).
  3. The document's string fields are stega-encoded — invisible markers are woven into the text carrying { field, … } navigation data.
  4. 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.

src/routes/(site)/+layout.svelte
<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.

src/lib/schemaTypes/blogPost.ts
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:

src/routes/(site)/blog/[slug]/+page.svelte
<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:

FieldMeaning
fieldtop-level document field name (required, except inline — see below)
arrayIndexindex into an array field
blockIndex / blockKeya block within a Portable Text body
objectPathdotted 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):

src/routes/(site)/blog/[slug]/+page.server.ts
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:

src/lib/blog/Prose.svelte
<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:

src/lib/blog/BlogImage.svelte
<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 coverAlt above)
  • a value used as a CSS value or attribute keyword — e.g. style="text-align: {align}" or a class name. Markers make the value invalid, so the browser silently drops it.
  • a value used as a lookup / map key or a discriminantthemes[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:

Callout.svelte (a custom block)
// `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:

src/lib/server/site.ts
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:

+page.server.ts
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:

aphex.config.ts
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

ExportWhat it is
AphexVisualOverlayComponent — 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 / stegaDecodeLow-level marker encode/decode (you rarely need these — use ve.encode).
Edit on GitHub

Last updated on