Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions .agents/skills/sanity-live-cache-components/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
---
name: sanity-live-cache-components
description: Integrates Sanity Live with Next.js Cache Components in next-sanity v13+ apps. Sets up sanityFetch, <SanityLive>, Visual Editing, Presentation Tool, draft mode handling, and the three-layer (Page/Dynamic/Cached) component pattern with explicit perspective/stega prop-drilling. Use when configuring or migrating a Next.js app to cacheComponents with Sanity, when adding sanityFetch, when wiring <SanityLive>/<VisualEditing>, or when refactoring components that hardcode perspective/stega.
---

# 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](https://nextjs.org/docs/app/api-reference/directives/use-cache#draft-mode)).

## Prerequisites

- Next.js 16.2+ installed in the project (check `package.json` or run `pnpm list next` / `npm ls next` — don't use `pnpm view next version`, that reports the registry's latest, not what's installed).
- `AGENTS.md` exists, or [follow the guide](https://nextjs.org/docs/app/guides/ai-agents#existing-projects).
- These environment variables are set:
- `NEXT_PUBLIC_SANITY_PROJECT_ID`
- `NEXT_PUBLIC_SANITY_DATASET`
- `SANITY_API_READ_TOKEN`
- Embedded Sanity Studio configuration (`sanity.config.ts`, `sanity.cli.ts`, anything under `sanity/`) needs no changes — this skill only touches the Next.js app surface.

## Reference files

| File | When to read |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| [reference/live-helpers.md](reference/live-helpers.md) | Full `client.ts` / `live.ts`, `sanityFetch*` and `getDynamicFetchOptions` details |
| [reference/three-layer-pattern.md](reference/three-layer-pattern.md) | The Page → Dynamic → Cached pattern for `page.tsx`, including the `searchParams` variant |
| [reference/layouts.md](reference/layouts.md) | Non-blocking data fetching inside `layout.tsx` with a shared `'use cache'` helper |
| [reference/dynamic-segments.md](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`

```bash
npm install next-sanity@^13 --save-exact
```

### Migrating 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 `token` and `stega.*` settings — see [reference/live-helpers.md](reference/live-helpers.md).
- **Search the codebase for hardcoded `perspective: 'published'` and `stega: false`** in `sanityFetch` callsites and refactor them to source `perspective`/`stega` via `getDynamicFetchOptions` and 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.

```ts
// 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:

```ts
// 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](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.

- `includeDrafts` is **required** when `defineLive` is configured with `strict: true` (the recommended setup). TypeScript will surface the error if it's missing; pass `includeDrafts={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.

```tsx
// 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](https://nextjs.org/docs/app/api-reference/file-conventions/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:

```text
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](reference/three-layer-pattern.md).
- **`page.tsx`** that uses `searchParams` or other dynamic APIs → the `searchParams` variant in [reference/three-layer-pattern.md](reference/three-layer-pattern.md).
- **`layout.tsx`** that fetches its own data → [reference/layouts.md](reference/layouts.md).
- **Dynamic `[slug]` route** that needs the `loading.tsx` + partial `generateStaticParams` optimization, or a layout that needs non-blocking `params` → [reference/dynamic-segments.md](reference/dynamic-segments.md).

---

## Anti-patterns to grep for

When auditing an app, search for these and refactor:

- `perspective: 'published'` and `stega: false` hardcoded together in a `sanityFetch` call → use the three-layer pattern, source `perspective`/`stega` via `getDynamicFetchOptions`.
- `sanityFetch(` directly inside a function whose body begins with `'use server'` → split into a separate `'use cache'` helper.
- `sanityFetch(` inside `generateStaticParams` → swap for `sanityFetchStaticParams`.
- `sanityFetch(` inside `generateMetadata` / `generateViewport` / `sitemap.ts` / `robots.ts` / `opengraph-image.tsx` etc. → swap for `sanityFetchMetadata` and resolve `perspective` via `getDynamicFetchOptions`.
- `await draftMode()` immediately followed by `await getDynamicFetchOptions()` at the top of a `page.tsx` or `layout.tsx` without a sibling `loading.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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# High-performance dynamic segments

[Dynamic routes](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes) should always implement `generateStaticParams`, even if only a subset of pages — see [the Cache Components note on dynamic routes](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes#with-cache-components). Whether to use `loading.tsx` or `<Suspense>` for fallback UI depends on the use case — see [the streaming guide](https://nextjs.org/docs/app/guides/streaming#when-to-use-loadingjs-vs-suspense).

## Contents

- [Case 1: `page.tsx` with `loading.tsx` + partial `generateStaticParams`](#case-1-pagetsx-with-loadingtsx--partial-generatestaticparams)
- [Case 2: `layout.tsx` with non-blocking dynamic `params`](#case-2-layouttsx-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.tsx` fallback.

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:

```tsx
// src/app/[slug]/loading.tsx
export default function Loading() {
return (
<article aria-busy>
<p>Loading…</p>
</article>
)
}
```

```tsx
// 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](https://nextjs.org/docs/app/getting-started/project-structure#component-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.

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