NetcaneTechnologies

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.

Yasir Haleem4 min read

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 strings
  • lib/sanity.image.ts@sanity/image-url builder 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.

About the author

Yasir Haleem is founder and lead engineer at Netcane Technologies. He builds production Next.js sites with headless CMS platforms — Strapi, Contentful, Sanity, and WordPress — with a focus on performance, SEO, and maintainable architecture.

Let's work together

Tell us about your project. We respond within one business day with a clear scope, timeline, and estimate.