Sanity CMS + Next.js App Router: a practical getting started guide
May 11, 2026 · 6 min read
This guide walks you through connecting Sanity CMS to a Next.js App Router project from scratch. If you've never touched Sanity before but you're comfortable with React and TypeScript, you're in the right place — by the end you'll have a working schema, a GROQ query, a Server Component rendering real content, and Sanity Studio mounted at /studio.
What you're building
A small content site with a post document type. The homepage lists posts fetched from Sanity. The studio lives at /studio inside the same Next.js app. No separate Sanity project repo, no extra deploy — one codebase.
File structure we're targeting:
my-site/
├── app/
│ ├── (site)/
│ │ └── page.tsx # homepage, Server Component
│ └── studio/
│ └── [[...tool]]/
│ └── page.tsx # embedded Sanity Studio
├── sanity/
│ ├── client.ts # Sanity client config
│ ├── queries.ts # GROQ queries
│ └── schemas/
│ ├── index.ts # schema registry
│ └── post.ts # post document type
├── sanity.config.ts # studio config
└── next.config.tsProject init
Start with a fresh Next.js project if you don't have one:
npx create-next-app@latest my-site --typescript --tailwind --app
cd my-siteThen install the Sanity packages you need:
npm install next-sanity sanitynext-sanity is the official integration package. It gives you a pre-configured client, a <VisualEditing> component, and helpers for live preview. sanity is the core SDK — it's what runs the Studio.
Go to sanity.io/manage and create a new project. Copy the project ID and dataset name (usually production). Add them to .env.local:
NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=productionDefining a schema
Sanity schemas are plain TypeScript objects. There's no magic — a schema is just a description of a document's fields.
Create the post schema first:
// sanity/schemas/post.ts
import { defineType, defineField } from 'sanity'
export const postSchema = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title' },
validation: (Rule) => Rule.required(),
}),
defineField({
name: 'publishedAt',
title: 'Published at',
type: 'datetime',
}),
defineField({
name: 'excerpt',
title: 'Excerpt',
type: 'text',
rows: 3,
}),
],
})Register it in the schema index:
// sanity/schemas/index.ts
import { postSchema } from './post'
export const schemaTypes = [postSchema]defineType and defineField are just typed wrappers — they don't do anything at runtime beyond helping TypeScript infer correct types. You can add as many document types as you need to the schemaTypes array.
Wiring up Sanity Studio at /studio
Create sanity.config.ts at the project root:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { schemaTypes } from './sanity/schemas'
export default defineConfig({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
plugins: [structureTool()],
schema: { types: schemaTypes },
})Now mount the Studio as a Next.js route. Because the Studio is a full client-side React app, this page must opt out of the App Router's Server Component default:
// app/studio/[[...tool]]/page.tsx
'use client'
import { NextStudio } from 'next-sanity/studio'
import config from '../../../sanity.config'
export default function StudioPage() {
return <NextStudio config={config} />
}The [[...tool]] catch-all segment lets the Studio's internal routing work correctly — without it, navigating between tools (Structure, Vision, etc.) would 404.
You'll also want to tell Next.js not to apply its default layout to the studio route. Add a layout.tsx inside app/studio/ that returns just the children:
// app/studio/layout.tsx
export default function StudioLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>
}Run npm run dev, visit http://localhost:3000/studio, and you should see the Sanity Studio. Create a couple of post documents so you have data to query.
Configuring the Sanity client
// 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: '2024-01-01',
useCdn: true,
})apiVersion pins the API version so behaviour doesn't change under you when Sanity ships updates. Use today's date or any past date — it doesn't need to be today.
useCdn: true serves responses from Sanity's global CDN. That's fine for most read operations. You'd set it to false when you need guaranteed fresh data, such as in draft mode or a route handler that runs post-publish.
Writing a GROQ query
GROQ is Sanity's query language. It looks unfamiliar at first but the mental model is simple: you filter a document set with [_type == "post"], then project the fields you want inside {...}.
// sanity/queries.ts
import { groq } from 'next-sanity'
export const postsQuery = groq`
*[_type == "post" && defined(slug.current)] | order(publishedAt desc) {
_id,
title,
"slug": slug.current,
publishedAt,
excerpt
}
`A few things to note:
*selects all documents in the dataset.[_type == "post" && defined(slug.current)]filters to published posts that have a slug.| order(publishedAt desc)sorts newest first.- The projection
{ ... }limits the payload to only the fields you need. This matters at scale — don't select*in production queries. "slug": slug.currentflattens the slug object to a plain string so you don't have to unwrap it in the component.
The groq tag from next-sanity is a no-op at runtime — it's purely for syntax highlighting and, if you use Sanity TypeGen, type generation.
Rendering posts in a Server Component
// app/(site)/page.tsx
import { client } from '../../sanity/client'
import { postsQuery } from '../../sanity/queries'
type Post = {
_id: string
title: string
slug: string
publishedAt: string | null
excerpt: string | null
}
export default async function HomePage() {
const posts = await client.fetch<Post[]>(postsQuery)
return (
<main className="mx-auto max-w-2xl px-4 py-16">
<h1 className="mb-10 text-3xl font-bold">Posts</h1>
<ul className="space-y-8">
{posts.map((post) => (
<li key={post._id}>
<a href={`/posts/${post.slug}`} className="text-xl font-semibold hover:underline">
{post.title}
</a>
{post.excerpt && (
<p className="mt-2 text-gray-600">{post.excerpt}</p>
)}
</li>
))}
</ul>
</main>
)
}Because this is an App Router Server Component, client.fetch runs on the server — no API routes, no useEffect, no client bundle cost for the fetch itself. Next.js caches the fetch result by default. To revalidate on a schedule, add an export:
export const revalidate = 60 // secondsOr use next: { tags: ['posts'] } as a fetch option and trigger tag-based revalidation from a Sanity webhook — but that's a separate topic.
Handling CORS for the client
When you call client.fetch from a Server Component, the request originates from your server, so CORS isn't a concern. But when you mount the Studio, the browser makes requests directly to the Sanity API. Go to sanity.io/manage, open your project, and add http://localhost:3000 under API > CORS origins. For production, add your deployed URL. Without this, the Studio will show auth errors.
What to do next
You now have the foundation: a schema, a working Studio, a GROQ query, and a Server Component reading real content. From here, the natural next steps are adding a Portable Text body field to post (install @portabletext/react), setting up ISR with webhook revalidation, and adding the slug route at app/(site)/posts/[slug]/page.tsx using generateStaticParams. Each of those builds directly on what you've wired up here.
Related posts
All posts →Sanity CMS vs Contentful for Next.js projects: an honest comparison
May 10, 2026 · 6 min read
Comparing Sanity CMS vs Contentful for Next.js teams: GROQ vs GraphQL, pricing, Studio DX, and where each platform wins on real projects.
How I Structure Sanity Schemas to Avoid Query Waterfalls in Next.js
Apr 27, 2026 · 5 min read
Denormalising references and embedding common fields in Sanity schemas cuts server component render time by 40–60%. Here's the pattern I ship.
How I wire Sanity webhooks to Next.js ISR revalidation with HMAC verification
May 11, 2026 · 6 min read
Step-by-step guide to connecting Sanity webhooks to Next.js route handlers for on-demand ISR revalidation, with HMAC signing and error handling.