This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
---
|
||||
title: Next.js & Sanity Integration Rules
|
||||
description: Integration guide for Next.js App Router, Live Content API, and Sanity Studio (Embedded or Standalone).
|
||||
---
|
||||
|
||||
# Next.js & Sanity Integration Rules
|
||||
|
||||
Jump to the section that matches the task instead of reading this guide end-to-end.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- Architecture patterns
|
||||
- Data fetching (Live Content API)
|
||||
- Caching and revalidation
|
||||
- Visual Editing and clean data
|
||||
- Embedded Studio setup
|
||||
- Draft Mode setup
|
||||
- Error handling
|
||||
- Presentation queries
|
||||
- Pagination pattern
|
||||
|
||||
## 1. Architecture Patterns
|
||||
|
||||
### Option A: Embedded Studio (Recommended)
|
||||
**Best for:** Most Next.js projects. Unified deployment, simpler setup.
|
||||
|
||||
The Studio lives inside your Next.js app at `/app/studio/[[...tool]]/page.tsx`.
|
||||
- **Config:** `sanity.config.ts` lives in the project root.
|
||||
- See `project-structure.md` rule for detailed structure.
|
||||
|
||||
### Option B: Monorepo (Alternative)
|
||||
**Best for:** Separation of concerns, multiple frontends, or strict dependency isolation.
|
||||
|
||||
The Studio and Next.js app live in separate folders:
|
||||
```
|
||||
apps/
|
||||
├── studio/ # Sanity Studio (standalone)
|
||||
└── web/ # Next.js frontend
|
||||
```
|
||||
|
||||
- **Config:** Add your Next.js app URL to **CORS Origins** in [Sanity Manage](https://www.sanity.io/manage).
|
||||
- See `project-structure.md` rule for detailed structure.
|
||||
|
||||
## 2. Data Fetching (Live Content API)
|
||||
|
||||
We use `defineLive` (next-sanity v11+) to enable real-time content updates and Visual Editing automatically.
|
||||
|
||||
### Setup (`src/sanity/lib/live.ts`)
|
||||
|
||||
```typescript
|
||||
import { defineLive } from 'next-sanity'
|
||||
import { client } from './client'
|
||||
|
||||
export const { sanityFetch, SanityLive } = defineLive({
|
||||
client: client.withConfig({
|
||||
apiVersion: '2026-02-01'
|
||||
}),
|
||||
serverToken: process.env.SANITY_API_READ_TOKEN,
|
||||
browserToken: process.env.SANITY_API_READ_TOKEN,
|
||||
})
|
||||
```
|
||||
|
||||
### Rendering (`src/app/layout.tsx`)
|
||||
|
||||
You **must** render `<SanityLive />` in the root layout to enable real-time updates.
|
||||
|
||||
```typescript
|
||||
import { SanityLive } from '@/sanity/lib/live'
|
||||
import { VisualEditing } from 'next-sanity/visual-editing'
|
||||
import { draftMode } from 'next/headers'
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
{children}
|
||||
<SanityLive />
|
||||
{(await draftMode()).isEnabled && <VisualEditing />}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Caching & Revalidation
|
||||
|
||||
### Prefer Live Content API (Default)
|
||||
|
||||
**Use `defineLive` by default.** It handles fetching, caching, and invalidation automatically. Only implement manual caching when you need fine-grained control.
|
||||
|
||||
### When to Use Manual Caching
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| Real-time updates, Visual Editing | `defineLive` (default) |
|
||||
| Static marketing pages, rarely updated | Time-based revalidation |
|
||||
| Blog posts, products with frequent edits | Tag-based revalidation |
|
||||
| Critical accuracy (stock levels, prices) | Path-based + short revalidation |
|
||||
|
||||
### Debugging: Enable Fetch Logging
|
||||
|
||||
See every fetch with cache HIT/MISS status:
|
||||
|
||||
```typescript
|
||||
// next.config.ts
|
||||
const nextConfig: NextConfig = {
|
||||
logging: {
|
||||
fetches: {
|
||||
fullUrl: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Console output shows cache status:
|
||||
```text
|
||||
GET /posts 200 in 39ms
|
||||
│ GET https://...apicdn.sanity.io/... 200 in 5ms (cache hit)
|
||||
```
|
||||
|
||||
### Sanity CDN vs API
|
||||
|
||||
| Setting | Speed | Freshness | Use When |
|
||||
|---------|-------|-----------|----------|
|
||||
| `useCdn: true` | Fast | May have brief delay | Default for all runtime fetches |
|
||||
| `useCdn: false` | Slower | Guaranteed fresh | `generateStaticParams`, webhooks |
|
||||
|
||||
Override per-request:
|
||||
```typescript
|
||||
// For static generation, use API directly
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await client
|
||||
.withConfig({ useCdn: false })
|
||||
.fetch(SLUGS_QUERY);
|
||||
return slugs;
|
||||
}
|
||||
```
|
||||
|
||||
### Manual `sanityFetch` Helper (Advanced)
|
||||
|
||||
For manual caching control, create a wrapper:
|
||||
|
||||
```typescript
|
||||
// src/sanity/lib/client.ts
|
||||
export async function sanityFetch<const QueryString extends string>({
|
||||
query,
|
||||
params = {},
|
||||
revalidate = 60,
|
||||
tags = [],
|
||||
}: {
|
||||
query: QueryString;
|
||||
params?: QueryParams;
|
||||
revalidate?: number | false;
|
||||
tags?: string[];
|
||||
}) {
|
||||
return client.fetch(query, params, {
|
||||
next: {
|
||||
revalidate: tags.length ? false : revalidate,
|
||||
tags,
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Time-Based Revalidation
|
||||
|
||||
Simple and predictable. Good for content that changes infrequently.
|
||||
|
||||
```typescript
|
||||
const posts = await sanityFetch({
|
||||
query: POSTS_QUERY,
|
||||
revalidate: 3600, // Revalidate every hour
|
||||
});
|
||||
```
|
||||
|
||||
**The "Typo Problem":** With time-based only, content authors may wait up to an hour to see changes. Use webhooks for instant updates.
|
||||
|
||||
### Path-Based Revalidation
|
||||
|
||||
Surgically revalidate specific routes when documents change.
|
||||
|
||||
**1. Create API Route:**
|
||||
```typescript
|
||||
// src/app/api/revalidate/path/route.ts
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { parseBody } from 'next-sanity/webhook';
|
||||
|
||||
type WebhookPayload = { path?: string };
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { isValidSignature, body } = await parseBody<WebhookPayload>(
|
||||
req,
|
||||
process.env.SANITY_REVALIDATE_SECRET,
|
||||
true // Add delay to allow CDN to update
|
||||
);
|
||||
|
||||
if (!isValidSignature) {
|
||||
return new Response('Invalid signature', { status: 401 });
|
||||
}
|
||||
if (!body?.path) {
|
||||
return new Response('Missing path', { status: 400 });
|
||||
}
|
||||
|
||||
revalidatePath(body.path);
|
||||
return NextResponse.json({ revalidated: body.path });
|
||||
} catch (err) {
|
||||
return new Response((err as Error).message, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Create GROQ-Powered Webhook:**
|
||||
- URL: `https://yoursite.com/api/revalidate/path`
|
||||
- Filter: `_type in ["post"]`
|
||||
- Projection: `{ "path": "/posts/" + slug.current }`
|
||||
- Add `SANITY_REVALIDATE_SECRET` to webhook and `.env.local`
|
||||
|
||||
### Tag-Based Revalidation
|
||||
|
||||
"Update once, revalidate everywhere" — best for referenced content.
|
||||
|
||||
**1. Tag Your Queries:**
|
||||
```typescript
|
||||
// Posts index - revalidate when ANY post, author, or category changes
|
||||
const posts = await sanityFetch({
|
||||
query: POSTS_QUERY,
|
||||
tags: ['post', 'author', 'category'],
|
||||
});
|
||||
|
||||
// Individual post - more granular, includes slug-specific tag
|
||||
const post = await sanityFetch({
|
||||
query: POST_QUERY,
|
||||
params,
|
||||
tags: [`post:${params.slug}`, 'author', 'category'],
|
||||
});
|
||||
```
|
||||
|
||||
**2. Create API Route:**
|
||||
```typescript
|
||||
// src/app/api/revalidate/tag/route.ts
|
||||
import { revalidateTag } from 'next/cache';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { parseBody } from 'next-sanity/webhook';
|
||||
|
||||
type WebhookPayload = { tags: string[] };
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { isValidSignature, body } = await parseBody<WebhookPayload>(
|
||||
req,
|
||||
process.env.SANITY_REVALIDATE_SECRET,
|
||||
true
|
||||
);
|
||||
|
||||
if (!isValidSignature) {
|
||||
return new Response('Invalid signature', { status: 401 });
|
||||
}
|
||||
if (!Array.isArray(body?.tags) || !body.tags.length) {
|
||||
return new Response('Missing tags', { status: 400 });
|
||||
}
|
||||
|
||||
body.tags.forEach((tag) => revalidateTag(tag));
|
||||
return NextResponse.json({ revalidated: body.tags });
|
||||
} catch (err) {
|
||||
return new Response((err as Error).message, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Create GROQ-Powered Webhook:**
|
||||
- URL: `https://yoursite.com/api/revalidate/tag`
|
||||
- Filter: `_type in ["post", "author", "category"]`
|
||||
- Projection: `{ "tags": [_type, _type + ":" + slug.current] }`
|
||||
|
||||
### Stale Data After Webhook?
|
||||
|
||||
Webhooks fire *before* Sanity CDN updates. If you see stale data:
|
||||
|
||||
1. **Add delay** — Pass `true` as third arg to `parseBody`
|
||||
2. **Or bypass CDN** — Set `useCdn: false` in client config (use sparingly)
|
||||
|
||||
## 4. Visual Editing (Stega) & Clean Data
|
||||
|
||||
Visual Editing injects invisible characters into strings to enable click-to-edit.
|
||||
|
||||
### A. The Golden Rule of Stega
|
||||
|
||||
If a string field controls logic (alignment, colors, IDs), you **must** clean it before comparing.
|
||||
|
||||
```typescript
|
||||
import { stegaClean } from "@sanity/client/stega";
|
||||
|
||||
export function Layout({ align }: { align: string }) {
|
||||
// ❌ Bad: Will fail in Edit Mode due to invisible chars
|
||||
// if (align === 'center') ...
|
||||
|
||||
// ✅ Good: Clean the value first
|
||||
const cleanAlign = stegaClean(align);
|
||||
return <div className={cleanAlign === 'center' ? 'mx-auto' : ''} />
|
||||
}
|
||||
```
|
||||
|
||||
### B. Metadata & SEO (Critical)
|
||||
|
||||
**Never** let Stega characters leak into `<head>` tags. Always set `stega: false` for metadata fetching.
|
||||
|
||||
```typescript
|
||||
export async function generateMetadata({ params }) {
|
||||
const { data } = await sanityFetch({
|
||||
query: SEO_QUERY,
|
||||
params: await params,
|
||||
stega: false // 👈 Critical for SEO
|
||||
})
|
||||
return { title: data?.title }
|
||||
}
|
||||
```
|
||||
|
||||
### C. Static Params
|
||||
|
||||
When generating static params, fetch only published content and disable stega.
|
||||
|
||||
```typescript
|
||||
export async function generateStaticParams() {
|
||||
const { data } = await sanityFetch({
|
||||
query: SLUGS_QUERY,
|
||||
perspective: 'published', // 👈 No drafts
|
||||
stega: false
|
||||
})
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Setup: Embedded Studio
|
||||
|
||||
Mount the Studio on a Next.js route.
|
||||
|
||||
**`src/app/studio/[[...tool]]/page.tsx`:**
|
||||
|
||||
```typescript
|
||||
import { NextStudio } from 'next-sanity/studio'
|
||||
import config from '../../../../sanity.config'
|
||||
|
||||
export const dynamic = 'force-static'
|
||||
export { metadata, viewport } from 'next-sanity/studio'
|
||||
|
||||
export default function StudioPage() {
|
||||
return <NextStudio config={config} />
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Setup: Draft Mode
|
||||
|
||||
Enable Presentation Tool and Visual Editing by setting up a draft mode route.
|
||||
|
||||
**`src/app/api/draft-mode/enable/route.ts`:**
|
||||
|
||||
```typescript
|
||||
import { client } from '@/sanity/lib/client'
|
||||
import { defineEnableDraftMode } from 'next-sanity/draft-mode'
|
||||
import { token } from '@/sanity/lib/token' // Helper to get token
|
||||
|
||||
export const { GET } = defineEnableDraftMode({
|
||||
client: client.withConfig({ token }),
|
||||
})
|
||||
```
|
||||
|
||||
## 7. Error Handling
|
||||
|
||||
Use `notFound()` for missing documents. Common errors:
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| 401 Unauthorized | Invalid/missing token | Check `SANITY_API_READ_TOKEN` |
|
||||
| 403 Forbidden | CORS not configured | Add URL to CORS origins |
|
||||
| Query syntax error | Invalid GROQ | Test in Vision plugin first |
|
||||
| Empty result | Wrong filter/params | Log params, check `_type` spelling |
|
||||
|
||||
```typescript
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export default async function PostPage({ params }: Props) {
|
||||
const { data } = await sanityFetch({ query: POST_QUERY, params: await params })
|
||||
if (!data) notFound()
|
||||
return <Post data={data} />
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Presentation Queries (`usePresentationQuery`)
|
||||
|
||||
For faster live editing in the Presentation Tool, use `usePresentationQuery` to fetch only the specific block being edited, rather than re-rendering the entire page.
|
||||
|
||||
### Why Use This
|
||||
|
||||
- **Without:** Editing a hero title re-fetches the whole page, re-renders all blocks
|
||||
- **With:** Only the hero block re-fetches and re-renders
|
||||
|
||||
This is especially valuable for pages with many Page Builder blocks or complex Portable Text.
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
import { usePresentationQuery } from 'next-sanity/hooks'
|
||||
import { HERO_PRESENTATION_QUERY } from '@/sanity/lib/queries'
|
||||
|
||||
type HeroProps = {
|
||||
_key: string
|
||||
documentId: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
// ... other initial props from page query
|
||||
}
|
||||
|
||||
export function Hero({ _key, documentId, title, subtitle, ...rest }: HeroProps) {
|
||||
// Fetch block-specific data for faster updates in Presentation Tool
|
||||
const { data } = usePresentationQuery({
|
||||
query: HERO_PRESENTATION_QUERY,
|
||||
params: { documentId, blockKey: _key },
|
||||
})
|
||||
|
||||
// Use presentation data if available, fallback to initial server props
|
||||
const blockData = data?.heroBlock || { title, subtitle, ...rest }
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1>{blockData.title}</h1>
|
||||
{blockData.subtitle && <p>{blockData.subtitle}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### The Presentation Query
|
||||
|
||||
Create a query that targets the specific block by `_key`:
|
||||
|
||||
```typescript
|
||||
// queries.ts
|
||||
export const HERO_PRESENTATION_QUERY = defineQuery(`
|
||||
*[_id == $documentId][0]{
|
||||
_id,
|
||||
_type,
|
||||
"heroBlock": pageBuilder[_key == $blockKey && _type == "hero"][0]{
|
||||
title,
|
||||
subtitle,
|
||||
image,
|
||||
theme,
|
||||
// Include all fields the component needs
|
||||
}
|
||||
}
|
||||
`)
|
||||
```
|
||||
|
||||
### Passing Document Context
|
||||
|
||||
Your PageBuilder component needs to pass `documentId` to each block:
|
||||
|
||||
```typescript
|
||||
export function PageBuilder({ content, documentId }: { content: Block[]; documentId: string }) {
|
||||
return (
|
||||
<main>
|
||||
{content.map((block) => {
|
||||
switch (block._type) {
|
||||
case "hero":
|
||||
return <Hero key={block._key} documentId={documentId} {...block} />
|
||||
// ... other blocks
|
||||
}
|
||||
})}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### For Portable Text Blocks
|
||||
|
||||
The same pattern works for custom blocks inside Portable Text:
|
||||
|
||||
```typescript
|
||||
export const PTE_IMAGE_PRESENTATION_QUERY = defineQuery(`
|
||||
*[_id == $documentId][0]{
|
||||
"pteImageBlock": body[_key == $blockKey && _type == "pteImage"][0]{
|
||||
image,
|
||||
caption,
|
||||
alt
|
||||
}
|
||||
}
|
||||
`)
|
||||
```
|
||||
|
||||
**See also:** `visual-editing.md` for the conceptual overview and `page-builder.md` for full Page Builder patterns.
|
||||
|
||||
## 9. Pagination Pattern
|
||||
|
||||
For listing pages with many entries, use offset-based pagination with a count query.
|
||||
|
||||
### Queries
|
||||
```typescript
|
||||
// Paginated listing
|
||||
export const ARTICLES_QUERY = defineQuery(`
|
||||
*[_type == "article" && defined(slug.current)]
|
||||
| order(date desc) [$start...$end] {
|
||||
_id, title, "slug": slug.current, date
|
||||
}
|
||||
`);
|
||||
|
||||
// Total count for pagination UI
|
||||
export const ARTICLES_COUNT_QUERY = defineQuery(`
|
||||
count(*[_type == "article" && defined(slug.current)])
|
||||
`);
|
||||
```
|
||||
|
||||
### Listing Page
|
||||
```typescript
|
||||
const ENTRIES_PER_PAGE = 10;
|
||||
|
||||
export default async function BlogPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string }>
|
||||
}) {
|
||||
const { page: pageParam } = await searchParams;
|
||||
const page = parseInt(pageParam || "1");
|
||||
const start = (page - 1) * ENTRIES_PER_PAGE;
|
||||
const end = start + ENTRIES_PER_PAGE;
|
||||
|
||||
const [{ data: articles }, { data: total }] = await Promise.all([
|
||||
sanityFetch({ query: ARTICLES_QUERY, params: { start, end } }),
|
||||
sanityFetch({ query: ARTICLES_COUNT_QUERY })
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(total / ENTRIES_PER_PAGE);
|
||||
|
||||
return (
|
||||
<main>
|
||||
{articles.map(article => (
|
||||
<ArticleCard key={article._id} article={article} />
|
||||
))}
|
||||
<Pagination current={page} total={totalPages} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user