← All articles
Building a Next.js Site on Sanity: A Practical Guide
Build a Next.js site on Sanity the way a pro does: page-builder schema, GROQ queries, CMS images, SEO metadata and tag-based revalidation — with real code and the gotchas.

Wiring a Next.js + Sanity site is not the hard part — the schema tutorial takes an afternoon. The hard part is the decisions that only show up in production: how to keep GROQ projections from sprawling, how to render a pixel-perfect site before the dataset has a single document, and how to make the CMS feel instant without hammering your Sanity API on every request. This is the path I actually ship on client projects, with the code and the trade-offs — not the happy-path demo.
If you want the buyer's-eye view of why one partner owning both the motion layer and the CMS matters, read what a fullstack creative developer delivers. This post is the engineering underneath it.
Why Sanity for a Next.js site
Sanity is a headless, API-first CMS: content lives as structured JSON, you query it with GROQ, and rich text is Portable Text (an array of blocks, not an HTML string). That structure is exactly what you want behind a design-led Next.js build — you control the markup, so animation, WebGL, and layout stay in code while editors own the words and assets.
| Concern | How Sanity handles it |
|---|---|
| Content shape | You define schemas in code (versioned, reviewable) |
| Querying | GROQ — projections shape the exact payload the component needs |
| Rich text | Portable Text (structured blocks → your own React renderer) |
| Images | CDN with on-the-fly transforms (?w=&h=&fit=&auto=format) |
| Publishing | Real-time; pairs with Next.js on-demand revalidation |
For how it stacks up against Prismic, Strapi, and headless WordPress, see the headless CMS comparison.
The client and environment
One typed client, useCdn: true for the fast edge-cached API, and a strict env guard. The guard matters more than it looks:
// src/lib/sanity/client.ts
import { createClient } from "next-sanity";
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID?.trim();
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET?.trim() || "production";
// Treat an empty/whitespace env var as MISSING — a blank NEXT_PUBLIC_SANITY_DATASET
// on a misconfigured build host slips past an `undefined` check and makes
// createClient throw at module-eval, killing the build before any fetch runs.
if (!projectId) throw new Error("Missing NEXT_PUBLIC_SANITY_PROJECT_ID");
export const client = createClient({
projectId,
dataset,
apiVersion: "2025-02-19", // pin a date; never "latest"
useCdn: true,
});
Pin apiVersion to a date string so a GROQ behaviour change never silently alters your results. Trim env vars: an empty NEXT_PUBLIC_SANITY_DATASET="" is the classic "works locally, dies on Vercel" failure because it passes an undefined check but Sanity rejects it as a dataset name.
Page-builder schema and Portable Text
A page-builder lets editors compose a page from reorderable sections. Each section is a Sanity object; a page document holds an array of them:
// schema/page.ts
import { defineType, defineArrayMember } from "sanity";
export default defineType({
name: "page",
type: "document",
fields: [
{ name: "title", type: "string" },
{ name: "slug", type: "slug", options: { source: "title" } },
{
name: "sections",
type: "array",
of: [
defineArrayMember({ type: "heroSection" }),
defineArrayMember({ type: "richTextSection" }),
defineArrayMember({ type: "gallerySection" }),
],
},
{ name: "seo", type: "seo" },
],
});
On the render side, a single ModuleRenderer switches on _type → React component. Unknown _type? Log a dev-only warning and render null — never throw, or one stale document takes down the page:
function ModuleRenderer({ sections }: { sections: SectionData[] }) {
return sections.map((s) => {
switch (s._type) {
case "heroSection": return <Hero key={s._key} section={s} />;
case "richTextSection": return <RichText key={s._key} section={s} />;
case "gallerySection": return <Gallery key={s._key} section={s} />;
default:
if (process.env.NODE_ENV !== "production")
console.warn(`Unknown section _type: ${(s as { _type: string })._type}`);
return null;
}
});
}
Portable Text renders through @portabletext/react with your own component map, so an editor's rich text inherits your typography and link components rather than raw HTML:
import { PortableText } from "@portabletext/react";
<PortableText
value={section.body}
components={{
block: { h2: ({ children }) => <h2 className="editorial-h2">{children}</h2> },
marks: { link: ({ children, value }) => <LinkEffect href={value.href}>{children}</LinkEffect> },
}}
/>;
One spread projection, not per-type queries
The mistake I see most: a GROQ query with a projection per section type (_type == "hero" => {...}, _type == "gallery" => {...}). It grows with every section and drifts out of sync. The fix is unified field names across every section schema — heading, label, cta, items[], cover, visual — so one spread projection resolves them all. Plain-value fields ride the spread ... for free; you only project the fields that need GROQ resolution (assets, links, references):
*[_type == "page" && slug.current == $slug][0]{
_id,
title,
"slug": slug.current,
seo,
sections[]{
...,
_key,
_type,
"coverUrl": cover.asset->url,
"coverAlt": cover.caption,
items[]{
...,
"iconUrl": icon.asset->url,
cta{ label, newTab, "href": coalesce(url, "/" + page->slug.current) }
}
}
}
Add a new section with the same field names and it renders through the existing query — zero query edits. That single discipline is the difference between a CMS you can extend in minutes and one that fights you on every section.
Images: flatten in GROQ, skip urlFor in React
Components should never receive a Sanity image object. Resolve the URL, alt, and filename in the projection and hand the component plain strings. That kills the urlFor() + hotspot + next/image remotePatterns plumbing you'd otherwise thread everywhere:
"coverUrl": cover.asset->url,
"coverAlt": cover.caption,
"coverFilename": cover.asset->originalFilename
Give every image field a single caption field that doubles as alt text, then build a fallback chain so an image is never un-alt'd:
// "hero-dark-01.png" -> "hero dark 01"
const altFromSrc = (src: string) =>
(src.split("/").pop() ?? src).replace(/\.[a-z0-9]+$/i, "").replace(/[-_]+/g, " ").trim();
const alt = section.coverAlt || altFromSrc(section.coverFilename || section.coverUrl);
For internal links, resolve the href in GROQ too — a coalesce(select(...)) turns an internal reference into "/" + slug (and the home slug into "/"), so the component just renders { label, href, newTab } and never touches Sanity's link graph.
SEO metadata from the CMS
Centralise metadata in one lib/seo.ts builder and merge page-level SEO over site defaults — never inline generateMetadata per route. Wrap the fetcher in React's cache() so generateMetadata, the layout, and the page share one request per render:
import { cache } from "react";
export const getPageBySlug = cache(async (slug: string) => {
try {
return await client.fetch(pageBySlugQuery, { slug });
} catch {
return null; // design-first: render fallbacks when the CMS is empty/unreachable
}
});
export async function generateMetadata({ params }): Promise<Metadata> {
const page = await getPageBySlug((await params).slug);
return {
title: page?.seo?.metaTitle ?? page?.title ?? "…",
description: page?.seo?.metaDescription ?? "…",
openGraph: { images: page?.seo?.ogImageUrl ? [page.seo.ogImageUrl] : [] },
};
}
Every slug-addressed document type (post, project, page) should ship the same shared seo object, so editors get per-entry title/OG/canonical/noindex control by default — not a synthesized description from the summary field.
Revalidation: tag-based, driven by a webhook
You want the CDN speed of useCdn: true and near-instant publishing. The stable, documented answer is tag-based revalidation: tag each fetch with the document types it reads, then a Sanity webhook pings a route handler that calls revalidateTag.
Tag the fetch:
const page = await client.fetch(pageBySlugQuery, { slug }, {
next: { tags: ["page", `page:${slug}`] },
});
The webhook route, with signature verification via next-sanity/webhook:
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { parseBody } from "next-sanity/webhook";
import { NextResponse, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const { isValidSignature, body } = await parseBody<{ _type: string; slug?: { current?: string } }>(
req,
process.env.SANITY_REVALIDATE_SECRET,
);
if (!isValidSignature) return new NextResponse("Invalid signature", { status: 401 });
if (!body?._type) return new NextResponse("Bad payload", { status: 400 });
revalidateTag(body._type);
if (body.slug?.current) revalidateTag(`${body._type}:${body.slug.current}`);
return NextResponse.json({ revalidated: true, tag: body._type });
}
Add a GROQ-powered webhook in Sanity's manage dashboard pointing at that URL with the same secret, projecting { _type, slug }. One gotcha the docs call out: time-based and tag-based revalidation cannot be combined — if a fetch has tags, its revalidate value is ignored. Pick one model per fetch.
For smaller marketing sites where a minute of staleness is fine, skip the webhook entirely: useCdn: true + export const revalidate = 60 on the layout, pages, and sitemap. Add the webhook plumbing only when the client needs instant publish. This is the same fetch-cost lesson as why Next.js keeps hitting your CMS on every request — an untagged, uncached fetch quietly bills you (and stresses your database) on every visit, the way a low-traffic site can still hit its Neon limit.
Design-first: render before the dataset exists
Agency reality: the site ships pixel-perfect with an empty dataset, and content is wired afterward. So the site must never notFound() or blank-render just because Sanity is empty. Two rules make it robust:
- Fetchers swallow errors and return
null/[](see thetry/catchabove). - Every section takes an optional prop and falls back per field to the design's copy:
const DEFAULT_HEADING = "A consequence-free environment.";
export default function Hero({ section }: { section?: HeroSectionData }) {
const heading = section?.heading || DEFAULT_HEADING;
const items = section?.items?.length ? section.items : DEFAULT_ITEMS;
// renders fully with zero CMS content
}
Reserve notFound() for a genuinely missing slug on a detail route — never for "the CMS isn't set up yet." Your home route renders a hardcoded module composition until a page with slug home exists, and generateStaticParams is try/catch'd to [] so builds never fail on an empty CMS.
Gotchas worth the price of this article
| Gotcha | Fix |
|---|---|
createClient throws at build on a blank env var | Trim env vars; treat empty/whitespace as missing, then fall back |
| GROQ projections sprawl per section type | Unified field names + one spread projection |
urlFor() + hotspot + remotePatterns everywhere | Flatten url/caption/filename in GROQ; ship strings |
| Blank page because the dataset is empty | Optional section? props + per-field design fallbacks |
Publishing feels slow with useCdn: true | Tag fetches + webhook → revalidateTag |
revalidate value ignored on a tagged fetch | Time-based and tag-based can't combine — pick one per fetch |
apiVersion: "latest" | Pin a date string so GROQ behaviour is stable |
FAQ
Do I need @sanity/image-url and next/image?
Not if you flatten the asset URL in GROQ. Sanity's CDN does transforms via query params (?w=1200&auto=format), so you can hand plain URLs to next/image (or a bare <img>), and skip the urlFor() + hotspot plumbing for most layouts. Reach for @sanity/image-url only when you genuinely need hotspot/crop-aware art direction.
How do I get instant preview of drafts?
Layer draft-mode + the Presentation tool on top of this — but only when the client needs live preview. For most builds, tag-based revalidation on publish is enough, and it's far less plumbing.
GROQ vs GraphQL on Sanity?
GROQ is the native, more capable query language — projections, joins (->), and computed fields in one query. Use it unless a hard external constraint forces GraphQL.
Can I use this exact setup for a blog or a marketing site?
Yes — the page-builder pattern covers marketing pages, and slug-addressed document types (post, project) reuse the same shared seo object and revalidation. It scales from a one-pager to a content-heavy site.
Ship it with a partner who owns the whole stack
The value of a Next.js + Sanity build isn't the schema — it's the judgment around it: keeping projections lean, rendering design-first, and making publishing feel instant without melting your API budget. That's the same discipline behind every site I ship, from the projects archive to this one.
If you're planning a build — or migrating off a template or a tangled WordPress theme — let's talk. You get the motion and WebGL polish and a CMS your team can actually run.


