
Sanity Live Cache Components
- 304 installs
- 951 repo stars
- Updated July 31, 2026
- sanity-io/next-sanity
Use sanity-live-cache-components for development tasks
About
sanity-live-cache-components: A skill for development. This provides functionality for development workflows.
- sanity-live-cache-components
Sanity Live Cache Components by the numbers
- 304 all-time installs (skills.sh)
- +30 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #1,298 of 4,348 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sanity-io/next-sanity --skill sanity-live-cache-componentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 304 |
|---|---|
| repo stars | ★ 951 |
| Last updated | July 31, 2026 |
| Repository | sanity-io/next-sanity ↗ |
What it does
Use sanity-live-cache-components for development tasks
Files
Sanity Live + Cache Components
Wires next-sanity into a Next.js 16+ app with cacheComponents: true. Data is fetched with sanityFetch (which calls cacheTag/cacheLife internally), and <SanityLive> in the root layout revalidates cached content over an EventSource connection to Sanity Content Lake. Visual Editing and Presentation Tool are fully supported when draft mode is enabled.
Read the relevant guide in node_modules/next/dist/docs/ (when available) before writing code. If a guide conflicts with this skill, follow this skill.
This skill assumes familiarity with the next-cache-components skill — it covers 'use cache', cacheLife, cacheTag, and the cookies/headers/params rule. The only Sanity-relevant exception: await draftMode() is allowed inside 'use cache' (Next.js bypasses caching when draft mode is enabled — see the `use cache` reference).
Prerequisites
- Next.js 16.2+ installed in the project (check
package.jsonor runpnpm list next/npm ls next— don't usepnpm view next version, that reports the registry's latest, not what's installed). AGENTS.mdexists, or follow the guide.- These environment variables are set:
NEXT_PUBLIC_SANITY_PROJECT_IDNEXT_PUBLIC_SANITY_DATASETSANITY_API_READ_TOKEN- Embedded Sanity Studio configuration (
sanity.config.ts,sanity.cli.ts, anything undersanity/) needs no changes — this skill only touches the Next.js app surface.
Reference files
| File | When to read |
|---|---|
| reference/live-helpers.md | Full client.ts / live.ts, sanityFetch* and getDynamicFetchOptions details |
| reference/three-layer-pattern.md | The Page → Dynamic → Cached pattern for page.tsx, including the searchParams variant |
| reference/layouts.md | Non-blocking data fetching inside layout.tsx with a shared 'use cache' helper |
| reference/dynamic-segments.md | High-performance [slug] routes: loading.tsx + partial generateStaticParams, or non-blocking dynamic params in a layout |
---
1. Install next-sanity@^13
npm install next-sanity@^13 --save-exactMigrating an existing Sanity Live setup
If the app is already using defineLive, this skill is a refactor, not a rewrite. The 5-step sequence below still applies, but watch for these specific differences:
- Don't overwrite `client.ts` or `live.ts` if they exist. Append missing options. Preserve any existing
tokenandstega.*settings — see reference/live-helpers.md. - Search the codebase for hardcoded `perspective: 'published'` and `stega: false` in
sanityFetchcallsites and refactor them to sourceperspective/stegaviagetDynamicFetchOptionsand the three-layer pattern. - Search for `sanityFetch` calls inside `generateStaticParams` → swap for
sanityFetchStaticParams. - Search for `sanityFetch` calls inside `generateMetadata` / `sitemap.ts` / `opengraph-image.tsx` / etc. → swap for
sanityFetchMetadata. - Search for `sanityFetch` calls directly inside a `'use server'` function → split into a separate
'use cache'helper. - Verify there is exactly one `<SanityLive>` and one `<VisualEditing>` in the tree. Multiple renders are undefined behavior.
The "Anti-patterns to grep for" section at the bottom of this file lists the search patterns.
---
2. Configure next.config.ts
Enable cacheComponents and set cacheLife.default to sanity so default revalidation is 1 year (instead of 15 minutes). sanityFetch is optimized for on-demand revalidation and doesn't need time-based revalidation.
// next.config.ts
import type {NextConfig} from 'next'
import {sanity} from 'next-sanity/live/cache-life'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {default: sanity},
}
export default nextConfig---
3. Configure defineLive and export helpers
Create src/sanity/lib/client.ts and src/sanity/lib/live.ts. The minimal defineLive call:
// src/sanity/lib/live.ts (excerpt)
export const {SanityLive, sanityFetch} = defineLive({
client,
serverToken: token,
browserToken: token,
strict: true,
})Full file contents (including client.ts, getDynamicFetchOptions, sanityFetchMetadata, sanityFetchStaticParams) and per-helper guidance: reference/live-helpers.md.
The helpers exported from live.ts:
| Helper | Used in |
|---|---|
sanityFetch | 'use cache' components rendered from page.tsx / layout.tsx |
sanityFetchMetadata | generateMetadata, generateViewport, sitemap.ts, robots.ts, opengraph-image.tsx, etc. |
sanityFetchStaticParams | generateStaticParams only |
getDynamicFetchOptions | Resolving perspective/stega outside any 'use cache' boundary |
SanityLive | Rendered once in a root layout |
---
4. Render <SanityLive> in a root layout
<SanityLive> and <VisualEditing> both belong in a layout.tsx, never a page.tsx. Both must be rendered at most once across the whole tree — duplicate renders are undefined behavior.
includeDraftsis required whendefineLiveis configured withstrict: true(the recommended setup). TypeScript will surface the error if it's missing; passincludeDrafts={isDraftMode}so live revalidation includes drafts only in draft mode.- Preserve any existing optional callback props on
<SanityLive>when migrating:onError,onWelcome,onReconnect. They are commonly wired to a toast/notification helper and silently dropping them regresses UX.
// src/app/layout.tsx
import {SanityLive} from '@/sanity/lib/live'
import {VisualEditing} from 'next-sanity/visual-editing'
import {draftMode} from 'next/headers'
export default async function RootLayout({children}: LayoutProps<'/'>) {
const {isEnabled: isDraftMode} = await draftMode()
return (
<html lang="en">
<body>
{children}
<SanityLive includeDrafts={isDraftMode} />
{isDraftMode && <VisualEditing />}
</body>
</html>
)
}With an embedded Sanity Studio
If a route mounts NextStudio from next-sanity/studio (e.g. app/studio/[[...index]]/page.tsx), <SanityLive> must live in a layout the embedded studio doesn't share. Use route groups: put <SanityLive> in src/app/(website)/layout.tsx and keep the rest of the app under src/app/(website).
---
5. Apply the three-layer pattern to pages and layouts
Every route that should be statically prerendered uses the same shape:
Page/Layout (Layer 1: draftMode branch)
├── NOT draft mode → <CachedX perspective="published" stega={false} /> (no Suspense)
└── draft mode → <Suspense fallback={...}>
<DynamicX params={params} /> (Layer 2: awaits dynamic APIs)
└── <CachedX perspective={p} stega={s} /> (Layer 3: 'use cache')Critical rule: Only Layer 3 carries 'use cache'. The top-level Page / Layout must not have 'use cache' — it awaits params, searchParams, or cookies() (via getDynamicFetchOptions), and those dynamic APIs are forbidden inside 'use cache'. Layer 3 carrying 'use cache' is enough for the whole route to prerender into the static shell. Adding 'use cache' to the top-level function is the most common failure mode — TypeScript and the runtime will both complain.
Pick the right reference for the file you're editing:
- `page.tsx` with static or
generateStaticParams-backed params → reference/three-layer-pattern.md. - `page.tsx` that uses
searchParamsor other dynamic APIs → thesearchParamsvariant in reference/three-layer-pattern.md. - `layout.tsx` that fetches its own data → reference/layouts.md.
- Dynamic `[slug]` route that needs the
loading.tsx+ partialgenerateStaticParamsoptimization, or a layout that needs non-blockingparams→ reference/dynamic-segments.md.
---
Anti-patterns to grep for
When auditing an app, search for these and refactor:
perspective: 'published'andstega: falsehardcoded together in asanityFetchcall → use the three-layer pattern, sourceperspective/stegaviagetDynamicFetchOptions.sanityFetch(directly inside a function whose body begins with'use server'→ split into a separate'use cache'helper.sanityFetch(insidegenerateStaticParams→ swap forsanityFetchStaticParams.sanityFetch(insidegenerateMetadata/generateViewport/sitemap.ts/robots.ts/opengraph-image.tsxetc. → swap forsanityFetchMetadataand resolveperspectiveviagetDynamicFetchOptions.await draftMode()immediately followed byawait getDynamicFetchOptions()at the top of apage.tsxorlayout.tsxwithout a siblingloading.tsx→ move those dynamic-API calls into a child component wrapped in<Suspense>so the static shell can prerender.- More than one
<SanityLive>or<VisualEditing>rendered in the tree → consolidate to a single render in the right layout.
High-performance dynamic segments
Dynamic routes should always implement generateStaticParams, even if only a subset of pages — see the Cache Components note on dynamic routes. Whether to use loading.tsx or <Suspense> for fallback UI depends on the use case — see the streaming guide.
Contents
- Case 1: `page.tsx` with `loading.tsx` + partial `generateStaticParams`
- Case 2: `layout.tsx` with non-blocking dynamic `params`
Case 1: page.tsx with loading.tsx + partial generateStaticParams
generateStaticParams returns only the 100 most recently updated pages. A sibling loading.tsx renders fallback UI, so page.tsx itself can skip the <Suspense> wrapper. The same fallback UI is reused in draft mode.
This scales to thousands of pages without ballooning next build and without compromising UX in production:
- Prerendered pages load instantly.
- Pages not prerendered start rendering on
<Link>hover (or when scrolled into view), so on click: - If prerendering finished in time → serves instantly, no loading state.
- If not → instantly shows the cached
loading.tsxfallback.
Add a sibling src/app/[slug]/loading.tsx that renders the same skeleton you would otherwise pass to <Suspense>. Keep it cheap and free of layout shift:
// src/app/[slug]/loading.tsx
export default function Loading() {
return (
<article aria-busy>
<p>Loading…</p>
</article>
)
}// src/app/[slug]/page.tsx
import {
getDynamicFetchOptions,
sanityFetch,
sanityFetchStaticParams,
type DynamicFetchOptions,
} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'
export async function generateStaticParams() {
const pageSlugsQuery = defineQuery(
`*[_type == "page" && defined(slug.current)] | order(_updatedAt desc) [0...100]{"slug": slug.current}`,
)
const {data} = await sanityFetchStaticParams({query: pageSlugsQuery})
return data
}
// With sibling `loading.tsx`, skip the `<Suspense>` + `DynamicPage` indirection: await `params`
// and `getDynamicFetchOptions` directly inside `Page`.
export default async function Page({params}: PageProps<'/[slug]'>) {
const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
return <CachedPage slug={slug} perspective={perspective} stega={stega} />
}
async function CachedPage({
slug,
perspective,
stega,
}: Awaited<PageProps<'/[slug]'>['params']> & DynamicFetchOptions) {
'use cache'
const pageQuery = defineQuery(`*[_type == "page" && slug.current == $slug][0]`)
const {data} = await sanityFetch({
query: pageQuery,
params: {slug},
perspective,
stega,
})
return <article>{/* use `data` to render stuff */}</article>
}Case 2: layout.tsx with non-blocking dynamic params
A layout.tsx can't use loading.tsx for fallback UI — it's one level higher in the hierarchy. To fetch data that depends on dynamic params without blocking children from streaming, pass the unawaited params promise into a <Suspense> boundary and await it inside.
// src/app/(website)/[slug]/layout.tsx
import {getDynamicFetchOptions, sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'
import {Suspense} from 'react'
export default function WebsiteLayout({children, params}: LayoutProps<'/[slug]'>) {
return (
<>
{children}
{/* The footer renders below the fold, no fallback needed */}
<Suspense>
<DynamicFooter
// Don't await `params` here — pass the promise and await inside Suspense so `children` streams in parallel
params={params}
/>
</Suspense>
</>
)
}
async function DynamicFooter({params}: Pick<LayoutProps<'/[slug]'>, 'params'>) {
const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
return <Footer slug={slug} perspective={perspective} stega={stega} />
}
async function Footer({
slug,
perspective,
stega,
}: Awaited<LayoutProps<'/[slug]'>['params']> & DynamicFetchOptions) {
'use cache'
const footerQuery = defineQuery(`*[_type == "footer" && slug.current == $slug][0]`)
const {data} = await sanityFetch({query: footerQuery, params: {slug}, perspective, stega})
return <footer>{/* use `data` to render stuff */}</footer>
}Non-blocking layout patterns
When sanityFetch runs inside a layout.tsx, the goal is to keep children streaming and keep the static shell as large as possible.
Contents
- Rules
- Pattern: shared `'use cache'` helper per draft/published branch
- Anti-pattern: wrapping `children` in a single cached layout
- Simpler example: a single `<Footer>`
Rules
- The top-level
layout.tsxcomponent must notawaitdynamic APIs (other thandraftMode()) or fetch data.draftMode()is the lone exception because Next.js bypasses caching when it's enabled and adraftMode()-only top-level component still prerenders into the static shell. Anything else (cookies(),headers(),await params,await searchParams,sanityFetch) reduces the static shell or slows draft-mode streaming. - Push dynamic API calls down to the leaf that needs them.
- Extract shared data fetching into a reusable async
'use cache'helper so two components that need the same data don't both wait independently.
Pattern: shared 'use cache' helper per draft/published branch
// src/app/(website)/layout.tsx
import {getDynamicFetchOptions, sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'
import {draftMode} from 'next/headers'
import {Suspense} from 'react'
async function fetchSettings({perspective, stega}: DynamicFetchOptions) {
'use cache'
const settingsQuery = defineQuery(`*[_type == "settings"][0]`)
const {data} = await sanityFetch({query: settingsQuery, perspective, stega})
return data
}
export default async function WebsiteLayout({children}: LayoutProps<'/'>) {
const {isEnabled: isDraftMode} = await draftMode()
return (
<>
{isDraftMode ? (
<Suspense fallback={<NavbarFallback />}>
<DynamicNavbar />
</Suspense>
) : (
<CachedNavbar perspective="published" stega={false} />
)}
{children}
{isDraftMode ? (
<Suspense>
<DynamicFooter />
</Suspense>
) : (
<CachedFooter perspective="published" stega={false} />
)}
</>
)
}
async function DynamicNavbar() {
const {perspective, stega} = await getDynamicFetchOptions()
return <CachedNavbar perspective={perspective} stega={stega} />
}
async function CachedNavbar({perspective, stega}: DynamicFetchOptions) {
'use cache'
const data = await fetchSettings({perspective, stega})
return <Navbar data={data} />
}
async function DynamicFooter() {
const {perspective, stega} = await getDynamicFetchOptions()
return <CachedFooter perspective={perspective} stega={stega} />
}
async function CachedFooter({perspective, stega}: DynamicFetchOptions) {
'use cache'
const data = await fetchSettings({perspective, stega})
return <Footer data={data} />
}Anti-pattern: wrapping children in a single cached layout
This blocks children on the layout's data fetch and prevents the page itself from streaming in independently.
// src/app/(website)/layout.tsx
export default async function WebsiteLayout({children}: LayoutProps<'/'>) {
const {isEnabled: isDraftMode} = await draftMode()
if (isDraftMode) {
return (
<Suspense>
<DynamicWebsiteLayout>{children}</DynamicWebsiteLayout>
</Suspense>
)
}
return (
<CachedWebsiteLayout perspective="published" stega={false}>
{children}
</CachedWebsiteLayout>
)
}
async function CachedWebsiteLayout({
children,
perspective,
stega,
}: {children: ReactNode} & DynamicFetchOptions) {
'use cache'
const settingsQuery = defineQuery(`*[_type == "settings"][0]`)
const {data} = await sanityFetch({query: settingsQuery, perspective, stega})
return (
<>
<Navbar data={data} />
{children}
<Footer data={data} />
</>
)
}Simpler example: a single <Footer>
Useful as a sanity check when adapting the pattern to a layout with only one data-driven section:
// src/app/(website)/layout.tsx
import {getDynamicFetchOptions, sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'
import {draftMode} from 'next/headers'
import {Suspense} from 'react'
export default async function WebsiteLayout({children}: LayoutProps<'/'>) {
const {isEnabled: isDraftMode} = await draftMode()
return (
<>
{children}
{isDraftMode ? (
<Suspense fallback={<FooterFallback />}>
<DynamicFooter />
</Suspense>
) : (
<Footer perspective="published" stega={false} />
)}
</>
)
}
async function DynamicFooter() {
const {perspective, stega} = await getDynamicFetchOptions()
return <Footer perspective={perspective} stega={stega} />
}
async function Footer({perspective, stega}: DynamicFetchOptions) {
'use cache'
const footerQuery = defineQuery(`*[_type == "footer"][0]`)
const {data} = await sanityFetch({query: footerQuery, perspective, stega})
return <footer>{/* use `data` to render stuff */}</footer>
}
function FooterFallback() {
return (
<footer>
<p>Loading footer...</p>
</footer>
)
}The non-draft <Footer perspective="published" stega={false} /> is part of the static shell, so the whole layout is cached and revalidates only when content used by sanityFetch changes. In draft mode the layout still renders immediately from its static shell while <DynamicFooter> streams in.
Live helpers: client.ts and live.ts
Contents
- `client.ts`
- `live.ts`
- `sanityFetch`
- `sanityFetchMetadata`
- `getDynamicFetchOptions`
- `sanityFetchStaticParams`
- Anti-patterns to grep for
client.ts
Projects typically have a src/sanity/lib/client.ts that exports a createClient instance.
If no `client.ts` exists yet, use this shape as a starting point:
// src/sanity/lib/client.ts
import {createClient} from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
useCdn: true,
apiVersion: '2026-05-19',
perspective: 'published',
stega: {studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || 'http://localhost:3333'},
})If `client.ts` already exists, leave its structure alone. Templates often centralize env-var reads in a separate sanity/lib/api.ts with an assertValue helper — keep that. Append only what's missing.
- Use a modern
apiVersion(e.g. today's date as a hardcoded string). stega.studioUrlenables stega encoding. It can be a relative string when an embedded Studio is mounted viaNextStudiofromnext-sanity/studio, otherwise an absolute URL (typically env-driven).- Changing
apiVersionor removing existingstega.*options can break callers. - Never remove an existing
tokenfromcreateClient. Private datasets require a client token even for published-content fetches.
live.ts
Create src/sanity/lib/live.ts alongside client.ts. If it already exists, append only what's missing.
SANITY_API_READ_TOKEN must never reach the client bundle. If the project already keeps it in a dedicated server-only module (commonly src/sanity/lib/token.ts with import 'server-only' at the top), import the token from there instead of inlining the process.env read. The example below inlines it for brevity — swap in the existing module if there is one.
// src/sanity/lib/live.ts
import {type QueryParams} from 'next-sanity'
import {defineLive, resolvePerspectiveFromCookies, type LivePerspective} from 'next-sanity/live'
import {cookies, draftMode} from 'next/headers'
import {client} from './client'
const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
throw new Error('Missing SANITY_API_READ_TOKEN')
}
export const {SanityLive, sanityFetch} = defineLive({
client,
serverToken: token,
browserToken: token,
strict: true,
})
export interface DynamicFetchOptions {
perspective: LivePerspective
stega: boolean
}
export async function getDynamicFetchOptions(): Promise<DynamicFetchOptions> {
const {isEnabled: isDraftMode} = await draftMode()
if (!isDraftMode) {
return {perspective: 'published', stega: false}
}
const jar = await cookies()
const perspective = await resolvePerspectiveFromCookies({cookies: jar})
return {perspective: perspective ?? 'drafts', stega: true}
}
// For usage within `generateStaticParams`
export async function sanityFetchStaticParams<const QueryString extends string>({
query,
params = {},
}: {
query: QueryString
params?: QueryParams
}) {
'use cache'
const {data} = await sanityFetch({query, params, perspective: 'published', stega: false})
return {data}
}
// For usage within `generateMetadata` and `generateViewport`
export async function sanityFetchMetadata<const QueryString extends string>({
query,
params = {},
perspective,
}: {
query: QueryString
params?: QueryParams
perspective: LivePerspective
}) {
'use cache'
const {data} = await sanityFetch({query, params, perspective, stega: false})
return {data}
}sanityFetch
For fetching data in React Server Components that have a 'use cache' directive and are rendered (directly or transitively) from a page.tsx or layout.tsx.
perspectiveswitches between published, drafts, and specific Sanity Content Releases.stega: true(combined withstega.studioUrlincreateClientand<VisualEditing>in the root layout) renders click-to-edit overlays.getDynamicFetchOptionsresolvesperspectivefrom thesanity-preview-perspectivecookie, which<VisualEditing>manages when the app is rendered inside Presentation Tool's preview iframe.
The async function that calls sanityFetch must carry 'use cache' or 'use cache: remote', and must take perspective and stega as props. Never hardcode them.
Pattern:
import {sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'
async function CachedComponent({slug, perspective, stega}: {slug: string} & DynamicFetchOptions) {
'use cache'
const pageQuery = defineQuery(`*[_type == "page" && slug.current == $slug][0]`)
const {data} = await sanityFetch({query: pageQuery, params: {slug}, perspective, stega})
}Anti-pattern (hardcoded options break Visual Editing and content-release previewing):
async function CachedComponent({slug}: {slug: string}) {
'use cache'
const {data} = await sanityFetch({
query: pageQuery,
params: {slug},
perspective: 'published', // hardcoded
stega: false, // hardcoded
})
}sanityFetch inside server actions
'use server' boundaries cannot accept perspective/stega as props (server action inputs are untrusted). Resolve them inside the 'use server' function and forward them to a separate 'use cache' boundary:
import {getDynamicFetchOptions, sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'
async function fetchMore({page, perspective, stega}: {page: string} & DynamicFetchOptions) {
'use cache'
const pagesQuery = defineQuery(`*[_type == "page"][0...$page]`)
const {data} = await sanityFetch({query: pagesQuery, params: {page}, perspective, stega})
return data
}
async function renderMore({page}: {page: string}) {
'use server'
const {perspective, stega} = await getDynamicFetchOptions()
const data = await fetchMore({page, perspective, stega})
}Anti-patterns:
- Hardcoding
perspective/stegain the'use cache'helper. - Calling
sanityFetchdirectly inside'use server'— it bypasses caching entirely.
sanityFetch inside route.ts
Hardcode stega: false and resolve only perspective. Route handlers don't render a DOM next to <VisualEditing>, so stega encoding only inflates the payload (and can cause downstream errors).
sanityFetchMetadata
For fetching data inside generateMetadata, generateSitemaps, generateViewport, generateImageMetadata, and the file-based metadata routes (icon.tsx, apple-icon.tsx, manifest.ts, opengraph-image.tsx, twitter-image.tsx, robots.ts, sitemap.ts).
It's sanityFetch without stega (never wanted in these contexts) and without requiring 'use cache' at the callsite — the helper already provides it.
Presentation Tool can open an app in a standalone preview window, so the correct content release must still be reflected in <title> and friends. Always resolve perspective:
import {getDynamicFetchOptions, sanityFetchMetadata} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'
export async function generateMetadata({params}: PageProps<'/[slug]'>) {
const [{slug}, {perspective}] = await Promise.all([params, getDynamicFetchOptions()])
const pageQuery = defineQuery(`*[_type == "page" && slug.current == $slug][0]`)
const {data} = await sanityFetchMetadata({query: pageQuery, params: {slug}, perspective})
}Anti-pattern: hardcoding perspective: 'published' — content-release previewing won't work.
getDynamicFetchOptions
Resolves perspective and stega outside the 'use cache' boundary so they can be passed in as plain props. Calls cookies(), which is a dynamic API, so the call must live inside a <Suspense> boundary (or a route with a sibling loading.tsx) so it doesn't block the static shell from streaming.
Avoid calling getDynamicFetchOptions in the top-level body of a layout.tsx or page.tsx that should remain part of the static shell. The exception is routes that intentionally use a sibling loading.tsx for fallback UI (see dynamic-segments.md) — there the page can await getDynamicFetchOptions directly because loading.tsx provides the streaming fallback.
When Cache Components are enabled, <Suspense> boundaries determine the static shell. For fully prerendered routes, render the Suspense tree only when in draft mode — see three-layer-pattern.md.
sanityFetchStaticParams
Used inside generateStaticParams. stega is never wanted (the data feeds route params), and perspective cookies aren't available at build time anyway, so both are hardcoded.
- Never call
sanityFetchinsidegenerateStaticParams— always usesanityFetchStaticParams. - Never call
sanityFetchStaticParamsoutsidegenerateStaticParams.
Anti-patterns to grep for
When migrating an existing app, these are the strings to search for and refactor:
perspective: 'published'andstega: falsehardcoded together in asanityFetchcall → replace withperspectiveandstegaprops sourced fromgetDynamicFetchOptionsvia the three-layer pattern.sanityFetch(directly inside a function whose body starts with'use server'→ split into a separate'use cache'helper and forwardperspective/stegaas props.sanityFetch(insidegenerateStaticParams→ swap forsanityFetchStaticParams.sanityFetch(insidegenerateMetadata/generateViewport/sitemap.ts/robots.ts/opengraph-image.tsxetc. → swap forsanityFetchMetadataand resolveperspectiveviagetDynamicFetchOptions.await draftMode()immediately followed byawait getDynamicFetchOptions()at the top of apage.tsxorlayout.tsxwithout a siblingloading.tsx→ move the dynamic-API calls into a child component wrapped in<Suspense>so the static shell can prerender.
Three-layer component pattern
The core architecture for every route that can be fully statically prerendered and cached.
Contents
- Structure
- `generateStaticParams` for dynamic routes
- Layer 1: Page component
- Layer 2: Dynamic component
- Layer 3: Cached component
- `searchParams` and other dynamic APIs
Structure
Page/Layout (Layer 1)
├── NOT draft mode → <CachedX perspective="published" stega={false} /> (no Suspense)
└── draft mode → <Suspense fallback={...}>
<DynamicX params={params} /> (Layer 2)
└── <CachedX params={await params} perspective={p} stega={s} /> (Layer 3)generateStaticParams for dynamic routes
The examples below use /[slug]/page.tsx, which needs:
// src/app/[slug]/page.tsx
import {sanityFetchStaticParams} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'
export async function generateStaticParams() {
const pageSlugsQuery = defineQuery(
`*[_type == "page" && defined(slug.current)]{"slug": slug.current}`,
)
const {data} = await sanityFetchStaticParams({query: pageSlugsQuery})
return data
}For /layout.tsx or /page.tsx (no params), skip the params handling.
Layer 1: Page component
Calls draftMode() and branches:
// src/app/[slug]/page.tsx (continued)
import {draftMode} from 'next/headers'
import {Suspense} from 'react'
export default async function Page({params}: PageProps<'/[slug]'>) {
const {isEnabled: isDraftMode} = await draftMode()
if (isDraftMode) {
return (
<Suspense fallback={<PageFallback />}>
<DynamicPage
// do not await `params` here, it needs to be awaited in `<DynamicPage>` so the Suspense boundary works
params={params}
/>
</Suspense>
)
}
const {slug} = await params
return <CachedPage slug={slug} perspective="published" stega={false} />
}Notes:
Pagedoes not have a'use cache'directive.draftMode()is allowed inside'use cache', butPagealsoawaitsparams(and may callgetDynamicFetchOptions(), which readscookies()), and those dynamic APIs are not allowed inside'use cache'. It's enough for<CachedPage>(Layer 3) to carry'use cache'forPageto be prerendered as part of the static shell.- Requires
generateStaticParamsifparamsis used as input tosanityFetch. - Not in draft mode → no
<Suspense>boundary, maximizes the static shell. - In draft mode →
<DynamicPage />inside<Suspense>will suspend twice:
1. when <DynamicPage> awaits getDynamicFetchOptions() 2. when <CachedPage /> awaits sanityFetch with the resolved perspective/stega
A good fallback skeleton that doesn't cause layout shift is highly recommended.
Layer 2: Dynamic component
Resolves params, cookies(), and headers() outside the cache boundary and passes plain props in:
// src/app/[slug]/page.tsx (continued)
import {getDynamicFetchOptions} from '@/sanity/lib/live'
async function DynamicPage({params}: Pick<PageProps<'/[slug]'>, 'params'>) {
const [{slug}, {perspective, stega}] = await Promise.all([params, getDynamicFetchOptions()])
return <CachedPage slug={slug} perspective={perspective} stega={stega} />
}draftMode() is the only dynamic API allowed inside 'use cache', but in this pattern it isn't needed in Layer 3 because perspective and stega already encode the draft state.
Layer 3: Cached component
Has 'use cache' and only receives plain, serializable props:
// src/app/[slug]/page.tsx (continued)
import {sanityFetch, type DynamicFetchOptions} from '@/sanity/lib/live'
import {defineQuery} from 'next-sanity'
async function CachedPage({
slug,
perspective,
stega,
}: Awaited<PageProps<'/[slug]'>['params']> & DynamicFetchOptions) {
'use cache'
const pageQuery = defineQuery(`*[_type == "page" && slug.current == $slug][0]`)
const {data} = await sanityFetch({
query: pageQuery,
params: {slug},
perspective,
stega,
})
return <article>{/* use `data` to render stuff */}</article>
}searchParams and other dynamic APIs
If searchParams or other dynamic APIs are inputs to sanityFetch (or params is used without generateStaticParams or a loading.tsx), always render the <Suspense> tree and stop branching on `draftMode`. See the streaming guide for picking between loading.tsx and <Suspense>.
// src/app/[slug]/page.tsx (continued)
import {Suspense} from 'react'
// Do not export an async function here, to avoid accidentally blocking render while awaiting a dynamic API
export default function Page({params}: PageProps<'/[slug]'>) {
return (
<Suspense
// not optional — no draftMode branch means a missing skeleton causes massive layout shift
fallback={<PageFallback />}
>
<DynamicPage
// do not await `params` here, it needs to be awaited in `<DynamicPage>` so the Suspense boundary works
params={params}
/>
</Suspense>
)
}