Why Headless CMS Is the Right Move in 2026

More product teams and digital agencies are moving away from monolithic platforms like WordPress toward headless architectures — and for good reason. A headless CMS decouples your content layer from your presentation layer, giving developers full control over the frontend while letting content editors work in a clean, purpose-built interface.

For SMBs in Australia, Singapore, Canada, and the US, this setup means faster sites, easier scaling, and content that can be delivered to web, mobile, and beyond from a single source of truth.

In this tutorial, you'll build a fully functional headless CMS workflow using Next.js 15 (with the App Router) and Sanity v3. By the end, you'll have a project that fetches structured content from Sanity, renders it with Next.js, and supports live preview in the studio — the same stack the team at Lenka Studio uses on client projects that need editorial flexibility without sacrificing frontend performance.

What You'll Need

  • Node.js 20+ installed locally
  • A free Sanity account (sanity.io)
  • Basic familiarity with React and TypeScript
  • A package manager — pnpm is recommended
  • A Vercel account for deployment (optional but recommended)

Step 1: Scaffold Your Next.js 15 App

Start by creating a new Next.js project with the App Router and TypeScript enabled:

pnpm create next-app@latest my-headless-site --typescript --app --tailwind --eslint
cd my-headless-site

When prompted, accept the default App Router setup. This gives you the app/ directory structure that works best with React Server Components — critical for efficient data fetching from Sanity.

Install Sanity dependencies

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

The next-sanity package is the official toolkit that bridges Sanity with Next.js. It includes a pre-configured client, image helpers, and live preview utilities.

Step 2: Create and Configure Your Sanity Project

Run the Sanity CLI initialiser from within your project directory:

pnpm create sanity@latest -- --project-id YOUR_PROJECT_ID --dataset production

If you haven't created a project yet, head to sanity.io/manage, create a new project, and copy your Project ID. Choose production as your dataset name for now.

Add environment variables

Create a .env.local file in your Next.js root:

NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_READ_TOKEN=your_read_token

Pro tip: Generate a read token in your Sanity project settings under API > Tokens. Set it to Viewer access. Never expose write tokens to the frontend.

Step 3: Configure the Sanity Client in Next.js

Create a shared client file at lib/sanity/client.ts:

import { createClient } from 'next-sanity'

export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2026-04-23',
  useCdn: process.env.NODE_ENV === 'production',
})

Setting apiVersion to today's date ensures you're always targeting the latest stable API behaviour. The useCdn flag enables Sanity's edge-cached API in production while keeping data fresh in development.

Step 4: Define Your Content Schema in Sanity

Navigate into your Sanity studio folder (typically studio/) and open the schema definitions. Create a simple blog post schema at studio/schemaTypes/post.ts:

import { defineField, defineType } from 'sanity'

export const postType = defineType({
  name: 'post',
  title: 'Blog Post',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      type: 'string',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'slug',
      type: 'slug',
      options: { source: 'title' },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'publishedAt',
      type: 'datetime',
    }),
    defineField({
      name: 'body',
      type: 'array',
      of: [{ type: 'block' }, { type: 'image' }],
    }),
  ],
})

Register it in studio/schemaTypes/index.ts:

import { postType } from './post'
export const schemaTypes = [postType]

Common pitfall: Don't nest schemas too deeply at this stage. Start flat and add complexity (like references to authors or categories) only after your base workflow is confirmed working end to end.

Step 5: Write GROQ Queries to Fetch Content

GROQ is Sanity's query language. Create a dedicated queries file at lib/sanity/queries.ts:

import { groq } from 'next-sanity'

export const allPostsQuery = groq`
  *[_type == "post"] | order(publishedAt desc) {
    _id,
    title,
    slug,
    publishedAt,
    "excerpt": array::join(string::split(pt::text(body), "")[0..1], "")
  }
`

export const postBySlugQuery = groq`
  *[_type == "post" && slug.current == $slug][0] {
    _id,
    title,
    slug,
    publishedAt,
    body
  }
`

GROQ projections ({ ... }) let you select only the fields you need, which keeps payloads lean — important for keeping your Largest Contentful Paint scores healthy.

Step 6: Render Content in Next.js App Router Pages

Blog index page

Create app/blog/page.tsx:

import { client } from '@/lib/sanity/client'
import { allPostsQuery } from '@/lib/sanity/queries'

export const revalidate = 60 // ISR: revalidate every 60 seconds

export default async function BlogPage() {
  const posts = await client.fetch(allPostsQuery)

  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {posts.map((post: any) => (
          <li key={post._id}>
            <a href={`/blog/${post.slug.current}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </main>
  )
}

Dynamic post page

Create app/blog/[slug]/page.tsx:

import { client } from '@/lib/sanity/client'
import { postBySlugQuery } from '@/lib/sanity/queries'
import { PortableText } from '@portabletext/react'

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await client.fetch(postBySlugQuery, { slug: params.slug })

  if (!post) return <p>Post not found.</p>

  return (
    <article>
      <h1>{post.title}</h1>
      <PortableText value={post.body} />
    </article>
  )
}

Install @portabletext/react to render Sanity's rich text blocks correctly:

pnpm add @portabletext/react

Pro tip: Customise the PortableText components object to map block types to your own styled React components — this gives editors full control without ever touching code.

Step 7: Enable Live Preview with Sanity's Presentation Tool

Live preview is what separates a polished content workflow from a frustrating one. Sanity v3's Presentation tool lets editors see changes reflected instantly in the Next.js frontend before publishing.

In your Sanity studio config (sanity.config.ts), add the Presentation plugin:

import { presentationTool } from 'sanity/presentation'

export default defineConfig({
  // ...
  plugins: [
    presentationTool({
      previewUrl: {
        origin: process.env.SANITY_STUDIO_PREVIEW_URL || 'http://localhost:3000',
        previewMode: {
          enable: '/api/draft-mode/enable',
        },
      },
    }),
  ],
})

Then create the draft mode API route in Next.js at app/api/draft-mode/enable/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')

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

  draftMode().enable()
  redirect(searchParams.get('redirect') || '/')
}

Add SANITY_PREVIEW_SECRET to your .env.local and match it in your Sanity environment variables on Vercel.

Step 8: Deploy to Vercel

Push your Next.js app to GitHub and connect it to Vercel. In your Vercel project settings, add all the environment variables from your .env.local.

For the Sanity studio, deploy it separately with:

cd studio && npx sanity deploy

This gives your content team a hosted studio at your-project.sanity.studio — no local setup required for editors.

Common pitfall: Forgetting to add CORS origins in Sanity's API settings. Head to sanity.io/manage > API > CORS Origins and add both your Vercel deployment URL and http://localhost:3000.

Step 9: Set Up a Webhook for On-Demand Revalidation

Rather than relying solely on time-based ISR, use Sanity webhooks to trigger Next.js revalidation instantly when content is published.

Create a revalidation route at app/api/revalidate/route.ts:

import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const secret = req.headers.get('x-webhook-secret')
  if (secret !== process.env.SANITY_WEBHOOK_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  revalidatePath('/blog', 'page')
  return NextResponse.json({ revalidated: true })
}

In Sanity's dashboard, create a webhook pointing to https://your-site.vercel.app/api/revalidate with a secret header. Now every time an editor publishes a post, your Next.js pages update within seconds — not minutes.

Next Steps

You now have a production-ready headless CMS workflow: structured content in Sanity, server-rendered pages in Next.js 15, live preview for editors, and instant cache revalidation on publish. From here, consider extending your schema with image galleries, author references, or localised content for multi-region audiences — especially useful if you're serving customers across Australia, Singapore, or North America from a single codebase.

If your content team needs richer editorial tooling, explore Sanity's custom input components and document actions to build workflow approvals directly into the studio. Pair this with a solid content planning process — a structured social media content calendar can help your team align publishing schedules across channels without the chaos.

Building this kind of architecture well takes considered decisions at every layer — schema design, caching strategy, preview infrastructure. If you'd like an experienced team to design and build your headless stack from the ground up, Lenka Studio specialises in exactly this kind of modern web architecture for growth-stage businesses. Get in touch and let's talk about what you're building.