Sanity
Sanity + Next.js: GROQ, Portable Text, and Live Preview
How to query Sanity with GROQ, render Portable Text in React, and set up preview without leaking tokens.
Sanity’s strength is a flexible schema and GROQ for precise queries. Next.js App Router fits naturally: fetch in Server Components, render Portable Text with @portabletext/react, and use Sanity’s preview perspective for drafts. This guide walks through a production setup we use on client projects.
Project structure
Typical layout:
sanity/— Studio schema and config (can be embedded or separate repo)lib/sanity.client.ts— read client (CDN in production)lib/sanity.queries.ts— GROQ stringslib/sanity.image.ts—@sanity/image-urlbuilder for optimized URLs
Keep GROQ in dedicated files so queries are testable and reusable across pages, sitemap generators, and webhooks.
Read client with CDN
// lib/sanity.client.ts
import { createClient } from "next-sanity";
export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!;
export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET ?? "production";
export const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION ?? "2024-01-01";
export const sanityRead = createClient({
projectId,
dataset,
apiVersion,
useCdn: process.env.NODE_ENV === "production",
});For preview, create a separate client with useCdn: false and a read token—never expose the token to the browser.
export function sanityPreviewClient() {
return createClient({
projectId,
dataset,
apiVersion,
useCdn: false,
token: process.env.SANITY_API_READ_TOKEN,
perspective: "previewDrafts",
});
}GROQ for pages and posts
GROQ lets you project exactly the fields you need and resolve references in one round trip.
// lib/sanity.queries.ts
export const postBySlugQuery = `
*[_type == "post" && slug.current == $slug][0]{
_id,
title,
"slug": slug.current,
excerpt,
publishedAt,
body,
"author": author->{ name, "avatar": image.asset->url },
"cover": {
"url": coverImage.asset->url,
"alt": coverImage.alt,
"lqip": coverImage.asset->metadata.lqip
},
seo
}
`;// lib/sanity.fetch.ts
import { sanityRead, sanityPreviewClient } from "./sanity.client";
import { postBySlugQuery } from "./sanity.queries";
export async function getPost(slug: string, preview = false) {
const client = preview ? sanityPreviewClient() : sanityRead;
return client.fetch(postBySlugQuery, { slug });
}Use parameters ($slug) instead of string interpolation to avoid injection and help query caching.
Portable Text in React
Block content from Sanity is Portable Text—not HTML. Map custom block types (callouts, CTAs, code) in one renderer component.
// components/portable-text.tsx
import { PortableText, type PortableTextComponents } from "@portabletext/react";
import Image from "next/image";
import { urlFor } from "@/lib/sanity.image";
const components: PortableTextComponents = {
types: {
image: ({ value }) => {
const src = urlFor(value).width(1200).url();
return (
<Image
src={src}
alt={value.alt ?? ""}
width={1200}
height={675}
className="rounded-xl"
/>
);
},
},
marks: {
link: ({ children, value }) => (
<a href={value.href} rel="noopener noreferrer" className="text-cyan-400 underline">
{children}
</a>
),
},
};
export function RichText({ value }: { value: unknown }) {
return <PortableText value={value} components={components} />;
}Register block types in Sanity schema (type: 'image', type: 'codeBlock') and handle each in components.types so editors get structured content instead of raw HTML paste.
Image URLs
// lib/sanity.image.ts
import imageUrlBuilder from "@sanity/image-url";
import { sanityRead } from "./sanity.client";
const builder = imageUrlBuilder(sanityRead);
export function urlFor(source: Parameters<typeof builder.image>[0]) {
return builder.image(source);
}Pass width, height, and fit('crop') at the call site. Use LQIP from metadata.lqip as placeholder="blur" when you store blur data in GROQ.
Preview route
Sanity Studio can open your site in preview with a secret:
// app/api/draft/route.ts
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get("secret");
const slug = searchParams.get("slug");
if (secret !== process.env.SANITY_PREVIEW_SECRET || !slug) {
return new Response("Unauthorized", { status: 401 });
}
(await draftMode()).enable();
redirect(`/blog/${slug}`);
}In page components, check draftMode().isEnabled and call getPost(slug, true).
Revalidation
On publish, Sanity webhooks can hit /api/revalidate with a shared secret. Revalidate by path (/blog/${slug}) or tag if you wrap sanityRead.fetch with Next cache tags. Studio’s “Deploy” button is not your deploy—treat webhook + ISR as the content refresh path.
Summary
Sanity + Next.js works best with GROQ in dedicated modules, a Portable Text renderer for all rich content, CDN reads in production, and previewDrafts perspective behind draft mode. Normalize images through @sanity/image-url, keep tokens server-side, and revalidate on publish so editors see changes without full redeploys.