Visual Editing

Keep Presentation fast on large pages

Cut preview latency on page-builder pages with per-section queries, scoped revalidation, narrow projections, and deferred sections.

On a page-builder page, a single edit in the Presentation Tool can rebuild the whole route on the server: header, footer, navigation, and every sibling section. On a page with a dozen sections, that turns a one-word change into several seconds of waiting.

This page collects four patterns that narrow what an edit re-renders. The patterns are framework-neutral, but how much each one buys you depends on how your front end receives live updates, so each section names the frameworks it applies to.

How your framework updates the preview

There are two update models, and they behave differently under editing load. Loader-based front ends keep a store per query on the client and let live mode patch each store independently. The Next.js App Router with the Live Content API refreshes the route instead.

FrameworkWhat an edit triggers in previewDo per-section queries isolate it?
React Router and Hydrogen (`@sanity/react-loader`, `hydrogen-sanity`)Live mode patches each query store over the Comlink channel. Mutations skip route revalidation while the Studio reports live preview as connected.Yes
SvelteKit (`@sanity/svelte-loader`)Live mode patches each query store. A manual refresh calls `invalidateAll()` and re-runs every `load` and remote `query` function.Yes
Next.js App Router (`next-sanity`)`<SanityLive />` calls `router.refresh()`. In draft mode, tag revalidation is skipped and the whole route re-renders.No

Patterns 1 and 2 depend on that difference. Patterns 3 and 4 help in every framework, and they are the main levers available in the Next.js App Router.

Prerequisites

Give each section its own query

Applies to: loader-based front ends, which includes React Router, Hydrogen, SvelteKit, and custom integrations built on the core loader.

Use this when a page is assembled from an array of sections and editors spend most of their time changing one section at a time.

One query for the whole page means one result. Any edit that touches the page produces a new result, and every section re-renders. Splitting the page into one query per section gives each section its own store, and a store only re-renders when its own result actually changes, so editing the hero leaves the sections below it untouched.

The larger effect is on the server. Once loader queries are mounted on the client, live mode delivers mutations over the Comlink channel and skips route revalidation, so an edit no longer costs a server round trip. Manual refreshes still revalidate. The skip is conditional: the adapter bypasses revalidation only while the Studio reports live preview as connected, and that signal is deprecated, so expect mutations to revalidate again in a future Studio major.

Give each section component its own query, seeded with the data your loader already fetched:

SvelteKit: the useQuery returned by createQueryStore in @sanity/svelte-loader wraps the same per-query store, so one call per section gives you the same isolation. See Visual Editing with SvelteKit for the setup. One caveat before you start: @sanity/svelte-loader 3.x ships no type declarations even though its package.json points at them, and because the SvelteKit template enables skipLibCheck the import resolves to any with no error, so nothing in your load functions is type-checked.

Next.js App Router: this pattern does not isolate updates, and splitting can cost you. In draft mode, <SanityLive /> refreshes the whole route instead of revalidating sync tags, and Next.js routes draft-mode requests past its Data Cache, so every sanityFetch on the page re-executes on every edit, as two uncached requests each, since sanityFetch looks up sync tags before fetching the result. One query per section means two round trips per section. Keep the page's queries few and narrow instead. Outside draft mode the route still re-renders, but sync tags scope which cache entries are invalidated, so untouched sections come from the Data Cache and splitting still pays off in production. Under cacheComponents: true, <SanityLive /> can no longer read draft mode itself and needs an explicit includeDrafts prop. See next-sanity: <SanityLive> strict mode and Sanity Live with Next.js Cache Components.

Gotcha

Keep shared data out of the revalidation path

Applies to: React Router, Hydrogen, and SvelteKit, where a manual refresh re-runs data functions across the whole route tree.

Use this when your navigation, header, or footer is fetched in a root loader that runs on every route change.

Live mode covers mutations once loaders are mounted, but a manual refresh still triggers a revalidation, and that refetches every loader in the route hierarchy. Scoping shouldRevalidate keeps nav and footer data out of that path.

In your root route, opt out of revalidation when the URL has not changed and nothing was submitted:

The formMethod and search guards matter. React Router revalidates after every submission and on any search-param change by design, and a bare pathname comparison swallows both, leaving nav and footer stale after a same-path mutation. Nothing in these arguments identifies Presentation either, so this also opts the root loader out of any other same-URL revalidation, including one you trigger yourself with useRevalidator.

SvelteKit: the same hazard exists, but it comes from the overlay rather than the loader. <VisualEditing /> in @sanity/visual-editing/svelte calls invalidateAll() when the reader clicks refresh, which re-runs every load and remote query function including the root layout. It skips only mutations, and only while live preview is connected. Marking loads with depends() is not enough on its own, because invalidateAll() forces every load to re-run whatever it depends on. Pass a refresh prop that calls invalidate('sanity:page') instead of the default handler, and mark the loads you want it to reach with depends('sanity:page').

Next.js App Router: there is no draft-mode equivalent. router.refresh() re-renders the route and its layouts together. A custom action prop is honored in draft mode but cannot help, because the tags it receives address the Next.js cache and draft mode has already bypassed it.

Narrow the projections you use in preview

Applies to: every framework, and it matters most in the Next.js App Router, where the whole route re-renders on each update.

Use this when your page-builder queries dereference other documents with ->, which most do once sections reference products, authors, or categories.

Server-side preview queries bypass the CDN whenever the perspective is draft-shaped, and in draft mode Next.js routes them past its own Data Cache as well, so editors never see stale content. Every dereference is paid in full on the initial render and on every manual refresh. A projection that costs nothing in production because it is cached can dominate preview latency. Once live mode is connected, the Studio runs the query and sends results over the Comlink channel instead, so a narrow projection also keeps those messages small.

Ask for the fields the section renders instead of the whole referenced document:

Narrowing the projection shrinks the response and the work needed to build it. It also reduces the number of documents the query touches, which narrows what a later edit can invalidate.

Defer sections that are not visible on load

Applies to: every framework that supports streaming.

Use this when a page has sections below the fold whose queries are slower than the ones above it.

A loader that awaits every section query blocks the whole response on the slowest one. Returning the slow queries as promises lets the preview stream, so the top of the page renders while the rest resolves.

In React Router and Hydrogen, return the promise from the loader without awaiting it, then resolve it in the component:

The outer Await is what resolves the deferred promise, so keep it: Query renders its own Suspense boundary only in preview mode, and that boundary covers its lazy client import rather than your loader data. Give each Suspense its own fallback instead of one spinner for the whole page. Watch the parameters you thread between sections: a null parameter makes references() match nothing, and a missing one fails the query outright, so a deferred section that renders empty is usually a bad pageId rather than missing content.

Next.js App Router: give the slow section its own async server component and wrap it in <Suspense>. The route still re-renders on every update, but the fast sections stream first. The result types come from TypeGen, so run npx sanity@latest typegen generate after changing a query, and render <SanityLive /> in the layout or nothing updates at all:

SvelteKit: return the un-awaited loadQuery() promise from load and resolve it in an {#await} block. Call useQuery inside a child component rendered in the then branch rather than in the callback itself: it calls onMount, so it has to run at component init. The query store also needs an already-resolved initial value, so pass the resolved result in.

Next steps

Was this page helpful?