50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 22, 2026·8 min read

Sanity CMS with Next.js — Headless Content Done Right

Sanity + Next.js is genuinely great, but the defaults will hurt you. Here's how we actually set it up for production.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Sanity CMS with Next.js — Headless Content Done Right

We've used a lot of CMS options over the years. Contentful (expensive the moment you breathe on it), Prismic (great until it isn't), Strapi (self-hosted, which means you're now a DevOps engineer), and a couple of bespoke ones we built ourselves and immediately regretted. Sanity keeps coming back as our default pick for Next.js projects, not because it's perfect, but because its trade-offs actually make sense for the way we build.

This isn't a tutorial about what Sanity is. It's about how to wire it into a Next.js App Router project without shooting yourself in the foot — covering GROQ queries, live preview, typed content, and the handful of gotchas we learned the unpleasant way.

Why Sanity Works Well With Next.js (And Where It Fights You)

The pitch is simple: Sanity stores your content, you query it with GROQ (their query language), and Next.js renders it. Content editors get a real studio UI, you get structured data, everyone's happy. In practice, this works really well for marketing sites, blogs, documentation, and any SaaS product that needs a content layer without a full-blown database schema for every piece of copy.

Where Sanity fights you: the JavaScript SDK has gone through breaking changes, the official Next.js integration package (`next-sanity`) has changed significantly between versions, and if you search for tutorials you'll find conflicting advice about whether to use CDN-cached reads or the real-time API. We'll sort all of this out.

Project Setup — Do This Once, Do It Right

Install the packages you actually need:

pnpm add sanity next-sanity @sanity/image-url @sanity/vision
pnpm add -D @sanity/types

Create a Sanity client that you use everywhere. Don't create a new client per file — you'll get inconsistent caching behavior and it's just messy.

// lib/sanity/client.ts
import { createClient } from 'next-sanity'

const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET ?? 'production'
const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION ?? '2024-01-01'

export const client = createClient({
  projectId,
  dataset,
  apiVersion,
  useCdn: process.env.NODE_ENV === 'production',
})

// Separate client for draft/preview — never use CDN for drafts
export const previewClient = createClient({
  projectId,
  dataset,
  apiVersion,
  useCdn: false,
  token: process.env.SANITY_API_READ_TOKEN,
  perspective: 'previewDrafts',
})

Notice the `useCdn` flag. In production, Sanity's CDN gives you cached responses that are fast and free from rate limits. For previews and draft content, you must use the live API — the CDN won't have your unpublished changes. Mixing these up is how you spend an hour wondering why your editor's changes aren't showing up anywhere.

Define Your Schema — Don't Skip the Types

Sanity's schema lives in code, which is one of its best features. Define it properly and you get TypeScript types almost for free. Here's a real blog post schema:

// sanity/schemas/post.ts
import { defineField, defineType } from 'sanity'

export const postType = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      validation: (Rule) => Rule.required().max(80),
    }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: { source: 'title' },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'publishedAt',
      type: 'datetime',
    }),
    defineField({
      name: 'excerpt',
      type: 'text',
      rows: 3,
      validation: (Rule) => Rule.max(200),
    }),
    defineField({
      name: 'mainImage',
      type: 'image',
      options: { hotspot: true },
      fields: [
        defineField({
          name: 'alt',
          type: 'string',
          title: 'Alt text',
        }),
      ],
    }),
    defineField({
      name: 'body',
      type: 'array',
      of: [{ type: 'block' }, { type: 'image' }],
    }),
  ],
  preview: {
    select: { title: 'title', media: 'mainImage' },
  },
})

Then wire it into your `sanity.config.ts` at the root. Keep the config simple — the studio URL should be `/studio` so editors know where to go.

// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { postType } from './sanity/schemas/post'

export default defineConfig({
  name: 'default',
  title: 'My Project Studio',
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET ?? 'production',
  plugins: [
    structureTool(),
    visionTool(), // Great for testing GROQ queries locally
  ],
  schema: {
    types: [postType],
  },
})

GROQ Queries — Learn This Language, It Pays Off

GROQ is Sanity's query language. It looks weird at first but it's genuinely good once you stop trying to write it like SQL. Keep your queries colocated with your data-fetching functions, not scattered across components.

// lib/sanity/queries.ts
import { groq } from 'next-sanity'
import { client } from './client'

const postFields = groq`
  _id,
  title,
  "slug": slug.current,
  excerpt,
  publishedAt,
  "mainImage": mainImage {
    asset->,
    alt
  }
`

export async function getAllPosts() {
  return client.fetch(
    groq`*[_type == "post" && defined(slug.current)] | order(publishedAt desc) {
      ${postFields}
    }`,
    {},
    { next: { tags: ['posts'] } } // App Router cache tag
  )
}

export async function getPostBySlug(slug: string) {
  return client.fetch(
    groq`*[_type == "post" && slug.current == $slug][0] {
      ${postFields},
      body
    }`,
    { slug },
    { next: { tags: [`post-${slug}`] } }
  )
}

A few things worth noting here. First, `slug.current` — Sanity stores slugs as objects with a `current` field, which trips up almost everyone the first time. Second, `asset->` is a GROQ join that dereferences the image asset reference to get the actual URL data. Without it you get a reference ID that's useless on its own. Third, the `next: { tags }` option hooks into Next.js's on-demand revalidation, which means you can invalidate exactly the content that changed instead of nuking your entire cache.

On-Demand Revalidation With Sanity Webhooks

Static generation is great for content sites — pages load instantly, your CDN is doing all the work. But you need a way to tell Next.js when Sanity content changes. Polling is amateur hour. Use webhooks.

Create a route handler for Sanity to POST to when content changes:

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
import { parseBody } from 'next-sanity/webhook'

export async function POST(req: NextRequest) {
  try {
    const { body, isValidSignature } = await parseBody<{
      _type: string
      slug?: { current: string }
    }>(req, process.env.SANITY_WEBHOOK_SECRET)

    if (!isValidSignature) {
      return NextResponse.json(
        { message: 'Invalid signature' },
        { status: 401 }
      )
    }

    if (!body?._type) {
      return NextResponse.json(
        { message: 'Bad request' },
        { status: 400 }
      )
    }

    // Revalidate the list tag always
    revalidateTag(`${body._type}s`)

    // If we have a specific slug, revalidate that page too
    if (body.slug?.current) {
      revalidateTag(`${body._type}-${body.slug.current}`)
    }

    return NextResponse.json({ revalidated: true, timestamp: Date.now() })
  } catch (err) {
    console.error(err)
    return NextResponse.json(
      { message: 'Error revalidating' },
      { status: 500 }
    )
  }
}

Then in Sanity's dashboard, create a webhook pointing to `https://yoursite.com/api/revalidate` with a secret header. Set the filter to `_type == "post"` so it only fires for post changes. Every time an editor publishes something, your pages revalidate within seconds. Editors feel like they have superpowers; you don't have to think about it again.

Always validate the webhook signature. Your revalidation endpoint is publicly accessible — without signature verification, anyone can spam it and hammer your build system.

Live Preview Without Losing Your Mind

Live preview is where most Sanity + Next.js setups get complicated. The `next-sanity` package has a `VisualEditing` component and a live preview system, but the docs are optimistic about how easy it is to set up. Here's the minimal version that actually works.

The pattern is: a preview route that sets a cookie, your page checks that cookie and switches to the draft client, and a `SanityLive` component that keeps content updated in real time.

// app/api/draft/route.ts — enables draft mode
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'

export async function GET(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get('secret')
  const slug = req.nextUrl.searchParams.get('slug') ?? '/'

  if (secret !== process.env.SANITY_PREVIEW_SECRET) {
    return new Response('Invalid token', { status: 401 })
  }

  const draft = await draftMode()
  draft.enable()
  redirect(slug)
}

// app/api/disable-draft/route.ts — disables it
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'

export async function GET() {
  const draft = await draftMode()
  draft.disable()
  redirect('/')
}

In your page component, check draft mode and swap clients accordingly:

// app/blog/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { client, previewClient } from '@/lib/sanity/client'
import { getPostBySlug } from '@/lib/sanity/queries'

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const { isEnabled: isDraft } = await draftMode()

  // Use preview client for drafts, regular client for production
  const post = isDraft
    ? await previewClient.fetch(
        /* your GROQ query */,
        { slug }
      )
    : await getPostBySlug(slug)

  if (!post) notFound()

  return (
    <article>
      {isDraft && (
        <div className="bg-yellow-100 p-2 text-sm">
          Preview mode —{' '}
          <a href="/api/disable-draft">exit</a>
        </div>
      )}
      <h1>{post.title}</h1>
      {/* render body */}
    </article>
  )
}

Rendering Portable Text (The Body Field)

Sanity's rich text format is called Portable Text. It's a structured JSON representation of block content that you render with the `@portabletext/react` package. Don't try to roll your own renderer — just use it.

// components/portable-text.tsx
import { PortableText, type PortableTextComponents } from '@portabletext/react'
import imageUrlBuilder from '@sanity/image-url'
import { client } from '@/lib/sanity/client'
import Image from 'next/image'

const builder = imageUrlBuilder(client)

const components: PortableTextComponents = {
  types: {
    image: ({ value }) => {
      if (!value?.asset?._ref) return null
      return (
        <div className="my-8">
          <Image
            src={builder.image(value).width(800).url()}
            alt={value.alt ?? ''}
            width={800}
            height={450}
            className="rounded-lg"
          />
        </div>
      )
    },
  },
  marks: {
    link: ({ children, value }) => (
      <a
        href={value.href}
        target={value.href.startsWith('http') ? '_blank' : undefined}
        rel={value.href.startsWith('http') ? 'noopener noreferrer' : undefined}
        className="underline text-blue-600"
      >
        {children}
      </a>
    ),
  },
  block: {
    h2: ({ children }) => (
      <h2 className="text-2xl font-bold mt-8 mb-4">{children}</h2>
    ),
    blockquote: ({ children }) => (
      <blockquote className="border-l-4 border-gray-300 pl-4 italic my-6">
        {children}
      </blockquote>
    ),
  },
}

export function PostBody({ content }: { content: unknown[] }) {
  return <PortableText value={content} components={components} />
}

Define your components once and reuse everywhere. The most common mistake we see is people rendering portable text without custom components and then wondering why their headings have no styles and their links open in the same tab.

The Image URL Problem — Get It Right Once

Sanity doesn't store image URLs directly — it stores asset references. You need `@sanity/image-url` to build the actual URL, and you want to specify dimensions to use Sanity's image transformation CDN instead of serving massive originals.

  • Always call `.width()` and/or `.height()` on your image URL builder — Sanity will resize it server-side
  • Use `.format('webp').auto('format')` for modern format optimization without thinking about it
  • The `hotspot: true` option on your schema field is only useful if you also pass `.fit('crop').crop('focalpoint')` in the URL builder
  • Add `sanity.io` (specifically `cdn.sanity.io`) to your `next.config.ts` `images.remotePatterns` or Next.js will refuse to optimize the images
// next.config.ts
import type { NextConfig } from 'next'

const config: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.sanity.io',
        port: '',
        pathname: '/images/**',
      },
    ],
  },
}

export default config

What We Actually Ship

When we build out a full content-driven Next.js project, the file structure around Sanity ends up looking like this:

  • `sanity/schemas/` — one file per document type
  • `sanity.config.ts` — studio config at the root
  • `app/studio/[[...tool]]/page.tsx` — embedded studio route
  • `lib/sanity/client.ts` — the two clients (CDN and preview)
  • `lib/sanity/queries.ts` — all GROQ queries with cache tags
  • `lib/sanity/image.ts` — image URL builder helper
  • `components/portable-text.tsx` — shared rich text renderer
  • `app/api/revalidate/route.ts` — webhook handler
  • `app/api/draft/route.ts` and `app/api/disable-draft/route.ts` — preview mode

If that sounds like a lot of boilerplate to set up from scratch every time you start a project — it is. It's exactly why we've built this into some of our peal.dev templates, so you start with the plumbing already done and can focus on the actual content model for your specific project.

Things That Will Bite You (Our Personal Hall of Shame)

  • Forgetting `useCdn: false` on the preview client. Your drafts will never appear. You'll spend an embarrassing amount of time checking the wrong things.
  • Not adding cache tags to queries. Fine until you need on-demand revalidation and discover you have to nuke the entire route cache instead of just the affected content.
  • Querying `slug` instead of `slug.current`. Sanity slugs are objects. You'll get `[object Object]` in your URL. Classic.
  • Embedding the Sanity studio at `/studio` but not protecting it. Add middleware to redirect non-authenticated users — you don't want random people poking around your content structure.
  • CORS errors in development because your localhost isn't in the Sanity project's CORS origins list. Check the Sanity dashboard under API settings.
The `@sanity/vision` plugin in your studio config is genuinely underused. It lets you run GROQ queries against your real data right in the studio. Use it to test queries before writing code — saves a full dev loop every time.

Sanity + Next.js is a genuinely good combo. The content modeling is flexible, GROQ is more expressive than REST once you get used to it, and the studio experience for editors is miles better than most alternatives. The setup cost is real but it's front-loaded — once it's wired up, adding a new content type is just a new schema file and a new GROQ query. That's a trade-off we're happy to make.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders