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.
| Framework | What an edit triggers in preview | Do 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
- A front end already set up for visual editing. See Visual Editing with React Router, Visual Editing with Next.js App Router, or Visual Editing with SvelteKit.
- One of
hydrogen-sanity5.0.0,@sanity/svelte-loader3.0.0, ornext-sanity13.0.2, or later. On React Router,@sanity/react-loaderat any current version. - A page whose content is modeled as an array of section objects.
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:
import {useLiveMode, useQuery} from '@sanity/react-loader'
import type {QueryResponseInitial} from '@sanity/react-loader'
import {client} from '~/sanity/client'
const HERO_QUERY = `*[_type == "page" && slug.current == $slug][0].hero`
type Hero = {heading: string; subheading?: string}
// Mount once, in preview mode only. Without it the sections render their
// initial data forever and encodeDataAttribute returns undefined.
export function LiveMode() {
useLiveMode({client})
return null
}
export function Hero({slug, initial}: {slug: string; initial: QueryResponseInitial<Hero>}) {
const {data, encodeDataAttribute} = useQuery<Hero>(HERO_QUERY, {slug}, {initial})
return (
<section data-sanity={encodeDataAttribute('heading')}>
<h1>{data.heading}</h1>
{data.subheading ? <p>{data.subheading}</p> : null}
</section>
)
}import {Query} from 'hydrogen-sanity'
const HERO_QUERY = `*[_type == "page" && slug.current == $slug][0].hero`
const PRODUCT_GRID_QUERY = `*[_type == "page" && slug.current == $slug][0].productGrid`
type Hero = {heading: string}
type ProductGrid = {title: string}
type LoaderData = {slug: string; hero: Hero; productGrid: ProductGrid}
export default function Page({loaderData}: {loaderData: LoaderData}) {
const {slug, hero, productGrid} = loaderData
return (
<>
<Query<Hero> query={HERO_QUERY} params={{slug}} options={{initial: hero}}>
{(data, encodeDataAttribute) => (
<h1 data-sanity={encodeDataAttribute('heading')}>{data.heading}</h1>
)}
</Query>
<Query<ProductGrid>
query={PRODUCT_GRID_QUERY}
params={{slug}}
options={{initial: productGrid}}
>
{(data) => <h2>{data.title}</h2>}
</Query>
</>
)
}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.
// loadQuery and useQuery must receive the identical query string, so the
// queries live in one module rather than being inlined at both call sites.
// Project off the document: `[0].hero` returns an inline object, which has
// no _id, so anything downstream needing the document id gets undefined.
export const heroQuery = `*[_type == "page" && slug.current == $slug][0]{
_id,
hero{heading, subheading}
}`
export const featuresQuery = `*[_type == "page" && slug.current == $slug][0]{
_id,
features[]{_key, title, body}
}`
export const reviewsQuery = `*[_type == "review" && page->slug.current == $slug]
| order(_createdAt desc)[0...20]{_id, author, rating, body}`
export const navQuery = `*[_type == "navigation" && _id == "navigation"][0]{
_id,
links[]{_key, title, "href": url}
}`
export type HeroResult = {
_id: string
hero: {heading: string | null; subheading: string | null} | null
} | null
export type FeaturesResult = {
_id: string
features: Array<{_key: string; title: string | null; body: string | null}> | null
} | null
export type ReviewsResult = Array<{
_id: string
author: string | null
rating: number | null
body: string | null
}>
export type NavResult = {
_id: string
links: Array<{_key: string; title: string | null; href: string | null}> | null
} | nullimport {featuresQuery, heroQuery, type FeaturesResult, type HeroResult} from '$lib/queries'
import type {PageServerLoad} from './$types'
export const load: PageServerLoad = async ({depends, params, locals: {loadQuery}}) => {
// The key the layout's scoped refresh handler invalidates.
depends('sanity:page')
const queryParams = {slug: params.slug}
// One loadQuery per section, so each section gets its own resolved
// QueryResponseInitial and its own useQuery store on the client.
const [hero, features] = await Promise.all([
loadQuery<HeroResult>(heroQuery, queryParams, {stega: true}),
loadQuery<FeaturesResult>(featuresQuery, queryParams, {stega: true}),
])
return {queryParams, hero, features}
}<script lang="ts">
import {useQuery, type QueryResponseInitial} from '@sanity/svelte-loader'
import {heroQuery, type HeroResult} from '$lib/queries'
let {
initial,
params,
}: {initial: QueryResponseInitial<HeroResult>; params: {slug: string}} = $props()
// useQuery calls onMount internally, so it has to run during component
// init. `initial` must be the resolved QueryResponseInitial, not a promise.
// svelte-ignore state_referenced_locally
const hero = useQuery<HeroResult>(heroQuery, params, {initial})
</script>
{#if $hero.data?.hero}
<section>
<h1 data-sanity={$hero.encodeDataAttribute(['hero', 'heading'])}>
{$hero.data.hero.heading}
</h1>
<p data-sanity={$hero.encodeDataAttribute(['hero', 'subheading'])}>
{$hero.data.hero.subheading}
</p>
</section>
{/if}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
encodeDataAttribute returns undefined until useLiveMode runs with a client that has stega.studioUrl set: that hook is the only thing that injects the Studio URL into the query store. It also returns undefined when the result carries no Content Source Map. Either way it fails silently, and click-to-edit stops working on that section.
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:
import type {ShouldRevalidateFunction} from 'react-router'
// Nav and footer come from the root loader. Skip refetching them when the
// URL hasn't changed and nothing was submitted, so a refresh in Presentation
// doesn't rebuild them.
export const shouldRevalidate: ShouldRevalidateFunction = ({
currentUrl,
nextUrl,
formMethod,
defaultShouldRevalidate,
}) => {
const sameUrl =
currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search
if (sameUrl && !formMethod) {
return false
}
return defaultShouldRevalidate
}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').
<script lang="ts">
import {useLiveMode} from '@sanity/svelte-loader'
import {VisualEditing, type VisualEditingProps} from '@sanity/visual-editing/svelte'
import {invalidate} from '$app/navigation'
import {client} from '$lib/sanity'
import Nav from '$lib/sections/Nav.svelte'
import {onMount} from 'svelte'
import type {LayoutData} from './$types'
let {data, children}: {data: LayoutData; children: import('svelte').Snippet} = $props()
// onMount is required, not optional: useLiveMode throws "Live mode is not
// supported in server environments" if it runs during SSR, so unlike
// useQuery it must NOT be called at component init. Returning its disable
// function gives Svelte the teardown.
//
// `client` is required. Passing {studioUrl} alone, as the package README
// suggests, throws "The `client` option in `enableLiveMode` is required".
onMount(() => {
if (!data.preview) return
return useLiveMode({client: client.withConfig({stega: true})})
})
const refresh: VisualEditingProps['refresh'] = (payload) => {
// Live mode already streams mutations into the individual stores, so
// there is nothing to re-fetch. Returning false skips the refresh and
// leaves Presentation's refresh button out of its loading state. Keep the
// livePreviewEnabled gate: without live mode the page would go stale.
// This branch fires twice per mutation (1s debounce), so keep it
// idempotent.
if (payload.source === 'mutation' && payload.livePreviewEnabled) return false
// Manual refresh, or a mutation with no live mode. The default handler
// calls invalidateAll(), which re-runs every load on the page including
// this layout's nav query. Scope it to the page instead.
return invalidate('sanity:page')
}
</script>
<Nav initial={data.nav} />
{@render children()}
{#if data.preview}
<VisualEditing {refresh} />
{/if}import {navQuery, type NavResult} from '$lib/queries'
import type {LayoutServerLoad} from './$types'
export const load: LayoutServerLoad = async ({depends, locals: {loadQuery, preview}}) => {
// Its own invalidation key, so a page-level refresh skips the nav and you
// can still refresh it deliberately with invalidate('sanity:nav').
depends('sanity:nav')
const nav = await loadQuery<NavResult>(navQuery, {}, {stega: true})
return {nav, preview}
}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:
*[_type == "page" && slug.current == $slug][0]{
...,
sections[]{
...,
products[]->
}
}*[_type == "page" && slug.current == $slug][0]{
_id,
title,
sections[]{
_key,
_type,
heading,
products[]->{
_id,
title,
"slug": slug.current,
// Ask for a sized, format-negotiated image. The bare asset URL is the
// full-size original, which is the largest thing on the page in preview.
"imageUrl": image.asset->url + "?w=400&auto=format"
}
}
}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:
import type {Route} from './+types/page'
const PAGE_QUERY = `*[_type == "page" && slug.current == $slug][0]{_id, hero}`
const REVIEWS_QUERY = `*[_type == "review" && references($pageId)]{quote, author}`
type Page = {_id: string; hero: {heading: string}}
export async function loader({context, params}: Route.LoaderArgs) {
// Awaited, and read here, so use `fetch` for the unwrapped result.
// `query` returns a union and can't be dereferenced without narrowing.
const page = await context.sanity.fetch<Page>(PAGE_QUERY, {slug: params.slug}, {tag: 'page'})
if (!page) {
throw new Response('Not found', {status: 404})
}
// Not awaited: streams in after the response starts. `query` returns the
// shape <Query> accepts as `initial`, so leave it wrapped.
const reviews = context.sanity.query(REVIEWS_QUERY, {pageId: page._id}, {tag: 'page.reviews'})
return {slug: params.slug, page, reviews}
}import {Suspense} from 'react'
import {Await} from 'react-router'
import {Query} from 'hydrogen-sanity'
const REVIEWS_QUERY = `*[_type == "review" && references($pageId)]{quote, author}`
type Reviews = {quote: string; author: string}[]
export function Reviews({pageId, reviews}: {pageId: string; reviews: Promise<Reviews>}) {
return (
<Suspense fallback={<p>Loading reviews</p>}>
<Await resolve={reviews}>
{(initial) => (
<Query<Reviews> query={REVIEWS_QUERY} params={{pageId}} options={{initial}}>
{(data) => (
<ul>
{data.map((review) => (
<li key={review.quote}>
{review.quote} <cite>{review.author}</cite>
</li>
))}
</ul>
)}
</Query>
)}
</Await>
</Suspense>
)
}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:
import {Suspense} from 'react'
import {notFound} from 'next/navigation'
import {defineQuery} from 'next-sanity'
import {sanityFetch} from '@/sanity/lib/live'
const PAGE_QUERY = defineQuery(`*[_type == "page" && slug.current == $slug][0]{_id, hero}`)
const REVIEWS_QUERY = defineQuery(`*[_type == "review" && references($pageId)]{quote, author}`)
// Its own server component, so the Suspense boundary can stream it in.
async function Reviews({pageId}: {pageId: string}) {
const {data} = await sanityFetch({query: REVIEWS_QUERY, params: {pageId}})
return (
<ul>
{data.map((review, index) => (
<li key={index}>{review.quote}</li>
))}
</ul>
)
}
export default async function Page({params}: {params: Promise<{slug: string}>}) {
const {slug} = await params
// Project _id off the document. `[0].hero` returns the field's value,
// which has no _id, and a missing param fails the reviews query.
const {data: page} = await sanityFetch({query: PAGE_QUERY, params: {slug}})
if (!page) {
notFound()
}
return (
<>
<h1>{page.hero?.heading}</h1>
<Suspense fallback={<p>Loading reviews</p>}>
<Reviews pageId={page._id} />
</Suspense>
</>
)
}import {createClient} from '@sanity/client'
import {defineLive} from 'next-sanity/live'
const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
apiVersion: '2026-09-01',
useCdn: false,
})
export const {sanityFetch, SanityLive} = defineLive({
client,
serverToken: process.env.SANITY_API_READ_TOKEN,
browserToken: process.env.SANITY_API_READ_TOKEN,
})import {SanityLive} from '@/sanity/lib/live'
export default function RootLayout({children}: {children: React.ReactNode}) {
return (
<html lang="en">
<body>
{children}
<SanityLive />
</body>
</html>
)
}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.
import {heroQuery, reviewsQuery, type HeroResult, type ReviewsResult} from '$lib/queries'
import type {PageServerLoad} from './$types'
export const load: PageServerLoad = async ({depends, params, locals: {loadQuery}}) => {
depends('sanity:page')
const queryParams = {slug: params.slug}
return {
queryParams,
// Awaited: blocks the response, ships in the initial HTML.
hero: await loadQuery<HeroResult>(heroQuery, queryParams, {stega: true}),
// Not awaited: streamed to the browser after the shell renders.
reviews: loadQuery<ReviewsResult>(reviewsQuery, queryParams, {stega: true}),
}
}<script lang="ts">
import Hero from '$lib/sections/Hero.svelte'
import Reviews from '$lib/sections/Reviews.svelte'
import type {PageData} from './$types'
let {data}: {data: PageData} = $props()
</script>
<!--
useQuery reads its params once, at component init, so a client-side
navigation to a different slug has to remount the sections. Without {#key}
every section store stays pinned to the previous slug.
-->
{#key data.queryParams.slug}
<Hero initial={data.hero} params={data.queryParams} />
<!--
The deferred section. The useQuery call lives in the child component
because it calls onMount and so must run at component init; it cannot be
called inside the body of the then branch.
-->
{#await data.reviews}
<p>Loading reviews…</p>
{:then initial}
<Reviews {initial} params={data.queryParams} />
{:catch}
<p>Reviews are unavailable.</p>
{/await}
{/key}<script lang="ts">
import {useQuery, type QueryResponseInitial} from '@sanity/svelte-loader'
import {reviewsQuery, type ReviewsResult} from '$lib/queries'
// `initial` is the resolved value handed down by the {#await ... then}
// block. useQuery cannot take a promise, and cannot be called inside the
// then branch's body either, which is why this is its own component.
let {
initial,
params,
}: {initial: QueryResponseInitial<ReviewsResult>; params: {slug: string}} = $props()
// svelte-ignore state_referenced_locally
const reviews = useQuery<ReviewsResult>(reviewsQuery, params, {initial})
</script>
<ul>
{#each $reviews.data as review, i (review._id)}
<li data-sanity={$reviews.encodeDataAttribute([i, 'body'])}>
<strong>{review.author}</strong>
{review.body}
</li>
{/each}
</ul>Next steps
- Live preview content updates: how live mode, the Comlink channel, and the query stores fit together, framework by framework.
- Visual editing architecture overview: where loaders sit in the wider visual editing system.
- Configuring the Presentation Tool: resolvers, allowed origins, and preview environments.