his prevents circular dependencies and ensures consistent caching behavior across server components.
// src/lib/sanity/client.ts
import { createClient } from "next-sanity";
export const contentClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET ?? "production",
apiVersion: "2024-01-01",
useCdn: false,
perspective: "published",
});
Why this structure? Isolating the client allows you to swap configurations for preview mode later without touching business logic. Setting useCdn: false ensures you always hit the latest published content, which is critical for editorial accuracy. The apiVersion pin guarantees query stability as Sanity evolves its backend.
Step 2: Schema Architecture & Studio Embedding
Define the content model using Sanity’s type system. Instead of generic naming, use domain-specific identifiers that align with your application’s data contracts.
// src/sanity/models/article.ts
import { defineField, defineType } from "sanity";
export const articleModel = defineType({
name: "article",
title: "Editorial Article",
type: "document",
fields: [
defineField({
name: "headline",
title: "Headline",
type: "string",
validation: (rule) => rule.required().min(10),
}),
defineField({
name: "routePath",
title: "URL Path",
type: "slug",
options: { source: "headline" },
validation: (rule) => rule.required(),
}),
defineField({
name: "summary",
title: "Summary",
type: "text",
rows: 2,
}),
defineField({
name: "heroMedia",
title: "Hero Image",
type: "image",
options: { hotspot: true },
}),
defineField({
name: "publicationDate",
title: "Published",
type: "datetime",
}),
defineField({
name: "contentBlocks",
title: "Body Content",
type: "array",
of: [{ type: "block" }, { type: "image" }],
}),
],
orderings: [
{
title: "Chronological (Newest)",
name: "dateDesc",
by: [{ field: "publicationDate", direction: "desc" }],
},
],
});
Register the model in the schema index. This step is mandatory; the Studio will not recognize unregistered types.
// src/sanity/models/index.ts
import { type SchemaTypeDefinition } from "sanity";
import { articleModel } from "./article";
export const contentSchema: { types: SchemaTypeDefinition[] } = {
types: [articleModel],
};
Why define fields explicitly? Sanity’s validation rules run in the Studio UI, preventing invalid data from entering the Content Lake. The slug field auto-generates URL-safe paths, while the array type with block and image leverages Portable Text—a structured JSON format that decouples content from HTML rendering. This ensures your frontend controls presentation, not the CMS.
Step 3: Data Fetching & Type Safety
GROQ queries should be isolated from components to maintain separation of concerns. Use template literals tagged with groq for syntax highlighting and query validation.
// src/lib/sanity/queries.ts
import { groq } from "next-sanity";
export const fetchArticleIndex = groq`
*[_type == "article"] | order(publicationDate desc) {
_id,
headline,
routePath,
summary,
publicationDate,
heroMedia {
asset-> { _id, url }
}
}
`;
export const fetchSingleArticle = groq`
*[_type == "article" && routePath.current == $path][0] {
_id,
headline,
routePath,
summary,
publicationDate,
heroMedia {
asset-> { _id, url }
},
contentBlocks
}
`;
export const fetchRouteSlugs = groq`
*[_type == "article"] { "path": routePath.current }
`;
TypeScript cannot infer GROQ results automatically. Define explicit interfaces to maintain type safety across the application.
// src/lib/sanity/types.ts
export interface SlugReference {
current: string;
}
export interface MediaAsset {
_id: string;
url: string;
}
export interface MediaField {
asset: MediaAsset;
}
export interface ArticleRecord {
_id: string;
headline: string;
routePath: SlugReference;
summary?: string;
publicationDate: string;
heroMedia?: MediaField;
contentBlocks?: unknown[];
}
Why manual types over auto-generation? While sanity typegen exists, explicit interfaces provide better control over optional fields, naming conventions, and downstream transformations. For production systems, consider generating types in CI pipelines to prevent drift without cluttering the codebase.
Step 4: Rendering with Next.js 16 App Router
Use server components for data fetching and leverage generateStaticParams for static generation. This combination delivers optimal performance while supporting dynamic routing.
// src/app/articles/page.tsx
import { contentClient } from "@/lib/sanity/client";
import { fetchArticleIndex } from "@/lib/sanity/queries";
import type { ArticleRecord } from "@/lib/sanity/types";
import Link from "next/link";
export default async function ArticleListing() {
const articles: ArticleRecord[] = await contentClient.fetch(
fetchArticleIndex,
{},
{ next: { revalidate: 3600 } }
);
return (
<section className="grid gap-6 md:grid-cols-2">
{articles.map((article) => (
<Link
key={article._id}
href={`/articles/${article.routePath.current}`}
className="p-4 border rounded-lg hover:bg-gray-50"
>
<h2 className="text-xl font-semibold">{article.headline}</h2>
<p className="text-gray-600 mt-2">{article.summary}</p>
</Link>
))}
</section>
);
}
For dynamic routes, combine static generation with live preview capabilities:
// src/app/articles/[slug]/page.tsx
import { contentClient } from "@/lib/sanity/client";
import { fetchSingleArticle, fetchRouteSlugs } from "@/lib/sanity/queries";
import type { ArticleRecord } from "@/lib/sanity/types";
import { notFound } from "next/navigation";
export async function generateStaticParams() {
const slugs = await contentClient.fetch(fetchRouteSlugs);
return slugs.map((item: { path: string }) => ({ slug: item.path }));
}
export default async function ArticleDetail({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article: ArticleRecord | null = await contentClient.fetch(
fetchSingleArticle,
{ path: slug },
{ next: { revalidate: 3600 } }
);
if (!article) notFound();
return (
<article className="max-w-2xl mx-auto py-12">
<h1 className="text-3xl font-bold">{article.headline}</h1>
<time className="text-gray-500 block mt-2">
{new Date(article.publicationDate).toLocaleDateString()}
</time>
<div className="mt-8 prose">
{/* Portable Text rendering would be injected here */}
</div>
</article>
);
}
Why this architecture? Next.js 16’s revalidate option enables incremental static regeneration, balancing performance with content freshness. generateStaticParams pre-renders known routes at build time, while fallback behavior handles new content without full rebuilds. Server components keep the payload lean by executing queries at the edge.
Pitfall Guide
-
Unregistered Schema Types
Explanation: Defining a model in a separate file without adding it to the schema index array causes the Studio to ignore it entirely.
Fix: Always export and register new types in the central schema index before starting the dev server.
-
Over-Projections in GROQ
Explanation: Requesting every field in a query increases payload size and slows down edge rendering, especially with nested references.
Fix: Use explicit projections ({ field1, field2 }) to fetch only what the UI requires. Omit heavy arrays like Portable Text on listing pages.
-
Cache Tag Misalignment
Explanation: Next.js 16 relies on cache tags for granular invalidation. Forgetting to tag Sanity fetches prevents targeted revalidation when content updates.
Fix: Attach tags: ["sanity-content"] to fetch options and call revalidateTag("sanity-content") in webhook handlers.
-
Type Drift Between Schema and Frontend
Explanation: Manual TypeScript interfaces diverge from Sanity schema changes, causing runtime type errors or missing fields.
Fix: Implement sanity typegen in CI pipelines or use a schema-to-type transformer to auto-sync interfaces on commit.
-
Live Preview Credential Exposure
Explanation: Storing preview tokens in client-side environment variables exposes them to browser inspection, risking unauthorized content access.
Fix: Keep preview tokens server-only. Use Next.js route handlers to proxy preview requests and set cookies securely.
-
Portable Text Rendering Without Custom Components
Explanation: Default Portable Text serializers output generic HTML, breaking design system consistency and accessibility standards.
Fix: Map Portable Text blocks to your UI component library using @portabletext/react with custom mark and block serializers.
-
Environment Variable Prefix Confusion
Explanation: Omitting NEXT_PUBLIC_ for project IDs breaks client-side preview functionality, while adding it to secret tokens leaks credentials.
Fix: Reserve NEXT_PUBLIC_ strictly for non-sensitive identifiers. Keep API tokens and preview secrets in server-only variables.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-traffic marketing site | Static generation + ISR (revalidate: 3600) | Minimizes edge compute, guarantees fast TTFB | Low (CDN-heavy) |
| Real-time editorial dashboard | Live preview + sanityFetch | Streams updates without page reloads | Medium (WebSocket overhead) |
| Large catalog (>10k items) | On-demand rendering + pagination | Avoids build-time bottlenecks | Medium-High (API calls) |
| Multi-language content | Locale-aware GROQ filters + i18n routing | Prevents cross-locale data leakage | Low (query complexity) |
Configuration Template
// src/lib/sanity/config.ts
import { createClient } from "next-sanity";
export const createSanityClient = (preview = false) =>
createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET ?? "production",
apiVersion: "2024-01-01",
useCdn: !preview,
perspective: preview ? "previewDrafts" : "published",
token: preview ? process.env.SANITY_API_TOKEN : undefined,
stega: preview,
});
Quick Start Guide
- Run
npx create-next-app@latest my-app --typescript --tailwind --app to scaffold the frontend
- Execute
npx sanity@latest init inside the project root, selecting TypeScript and /studio route
- Define your content model in
src/sanity/models/, register it, and start the dev server
- Write GROQ queries in a dedicated module, attach TypeScript interfaces, and fetch via server components
- Deploy to Vercel or your preferred edge provider; content updates will propagate through ISR or live preview automatically