> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# 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](https://www.sanity.io/docs/visual-editing/visual-editing-with-react-router), [Visual Editing with Next.js App Router](https://www.sanity.io/docs/visual-editing/visual-editing-with-next-js-app-router), or [Visual Editing with SvelteKit](https://www.sanity.io/docs/visual-editing/visual-editing-with-sveltekit).
- One of `hydrogen-sanity` 5.0.0, `@sanity/svelte-loader` 3.0.0, or `next-sanity` 13.0.2, or later. On React Router, `@sanity/react-loader` at 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:

**@sanity/react-loader**

```tsx
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>
  )
}

```

**hydrogen-sanity**

```tsx
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](https://www.sanity.io/docs/visual-editing/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.

**src/lib/queries.ts**

```typescript
// 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
} | null

```

**src/routes/[slug]/+page.server.ts**

```typescript
import {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}
}

```

**src/lib/sections/Hero.svelte**

```svelte
<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](https://www.sanity.io/docs/help/next-sanity-live-strict) and [Sanity Live with Next.js Cache Components](https://www.sanity.io/docs/nextjs/cache-components).

> [!WARNING]
> 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:

**app/root.tsx**

```tsx
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')`.

**src/routes/+layout.svelte**

```svelte
<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}

```

**src/routes/+layout.server.ts**

```typescript
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:

**Wide projection**

```groq
*[_type == "page" && slug.current == $slug][0]{
  ...,
  sections[]{
    ...,
    products[]->
  }
}

```

**Narrow projection**

```groq
*[_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:

**app/routes/page.tsx**

```typescript
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}
}

```

**app/sections/reviews.tsx**

```tsx
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:

**src/app/[slug]/page.tsx**

```tsx
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>
    </>
  )
}

```

**src/sanity/lib/live.ts**

```typescript
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,
})

```

**src/app/layout.tsx**

```tsx
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.

**src/routes/[slug]/+page.server.ts**

```typescript
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}),
  }
}

```

**src/routes/[slug]/+page.svelte**

```svelte
<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}

```

**src/lib/sections/Reviews.svelte**

```svelte
<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](https://www.sanity.io/docs/visual-editing/live-preview-content-updates): how live mode, the Comlink channel, and the query stores fit together, framework by framework.
- [Visual editing architecture overview](https://www.sanity.io/docs/visual-editing/visual-editing-architecture): where loaders sit in the wider visual editing system.
- [Configuring the Presentation Tool](https://www.sanity.io/docs/visual-editing/configuring-the-presentation-tool): resolvers, allowed origins, and preview environments.

