How to Integrate Sanity CMS with Next.js 16 App Router (2026 Guide)
Managing content as a developer used to mean one of two things: hardcoding everything into your codebase or wrestling with a clunky WordPress dashboard. Neither feels great when your client wants to update a blog post at 10pm on a Friday without touching a single line of code.
That's exactly where Sanity CMS shines. It's a headless CMS with a fully customizable studio, a powerful query language called GROQ, and first-class support for Next.js. And in 2026, with Next.js 16's App Router fully matured, the two work together beautifully.
In this guide, you'll set up Sanity CMS inside a Next.js 16 App Router project from scratch: creating a project, defining a schema, writing GROQ queries, typing everything with TypeScript, and rendering rich text content with live updates. By the end, you'll have a working blog listing page and a dynamic post page, both powered entirely by Sanity.
Why Sanity Over Other Headless CMS Options?
There are plenty of headless CMS choices out there — Contentful, Strapi, Payload — so why Sanity?
- Sanity Studio runs inside your Next.js app at
/studio, so there's no separate admin URL to manage. - GROQ (Graph-Relational Object Queries) is more flexible and readable than GraphQL for content queries.
- Real-time collaboration — multiple editors can work on content simultaneously.
- Free tier is genuinely generous for most personal projects and small clients.
What We're Building
- An embedded Sanity Studio running at
/studioinside your Next.js app - A
postschema with title, slug, excerpt, cover image, and rich text body - GROQ queries to fetch all posts and a single post by slug
- Fully typed Sanity responses with TypeScript
- A blog listing page and a dynamic post page using
generateStaticParams - Real-time content updates using Sanity Live
What Is Sanity, Exactly?
Sanity is a headless CMS — meaning the content editor (Studio) is completely decoupled from how your content gets displayed. You configure Studio with code, content gets stored remotely in what Sanity calls the Content Lake, and your Next.js app queries that content using GROQ (Sanity's own query language).
💡 Tip: Think of Sanity Studio as a "window" into your remote content. The Studio configuration lives in your repo, but the actual blog posts, images, and data live on Sanity's servers — not in your codebase.
Prerequisites
Before we start, make sure you have:
- Node.js 20+ installed
- A Next.js 16 project with the App Router (TypeScript + Tailwind)
- A free account at sanity.io
- Basic familiarity with Next.js file-based routing
Step 1: Create a Sanity Project
First, let's create a new Sanity project from the CLI. If you don't have the Sanity CLI installed yet, run:
npm install -g @sanity/cli
Then inside your Next.js project root, initialize Sanity:
npx sanity@latest init
The CLI will walk you through a series of prompts. Here's exactly what to choose:
✔ Create a new project or select an existing one → Create new project
✔ Project name → whatever you like (e.g. my-blog)
✔ Select organization or create new
✔ Use the default dataset configuration? → Yes
✔ Configure Sanity MCP and agent skills for these editors? → Claude Code, GitHub Copilot CLI, VS Code
✔ Would you like to add configuration files for a Sanity project in this Next.js folder? → Yes
✔ Do you want to use TypeScript? → Yes
✔ Would you like an embedded Sanity Studio? → Yes
✔ What route do you want to use for the Studio? → /studio
✔ Select project template to use → Clean project with no predefined schema types
✔ Would you like to add the project ID and dataset to your .env.local file? → Yes
⚠️ Common Mistake: Picking the "blog" template instead of "Clean project" gives you pre-built schema types (
post,author,category). That's fine if you want a head start, but this guide builds thepostschema manually so you understand exactly what each field does — so we go with the clean template.
Once it finishes, your project folder should look like this:
.
├── .env.local
├── sanity.cli.ts
├── sanity.config.ts
├── (...and all your Next.js files)
└── src
├── app
│ └── studio
│ └── [[...tool]]
│ └── page.tsx
└── sanity
├── lib
│ ├── client.ts
│ ├── image.ts
│ └── live.ts
├── schemaTypes
│ └── index.ts
├── env.ts
└── schema.ts
What Just Got Created?
A few of these files matter more than others:
src/sanity/lib/client.ts— a configured Sanity Client instance used to query contentsrc/sanity/lib/live.ts— exportssanityFetchandSanityLive, which power real-time content updatessrc/sanity/env.ts— reads your project ID and dataset from.env.localsanity.config.ts— configures Studio itself: schema types, plugins, and the studio routeapp/studio/[[...tool]]/page.tsx— the catch-all route that renders the entire Studio UI at/studio
Your .env.local should now contain:
NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id"
NEXT_PUBLIC_SANITY_DATASET="production"
These are prefixed with NEXT_PUBLIC_ because a project ID and dataset name aren't sensitive — they're needed by the browser to talk to Sanity's API directly when previewing content.
Step 2: Install next-sanity
The CLI usually installs this for you, but if it's missing, add it manually:
npm install next-sanity
next-sanity is a toolkit of utilities built specifically for Next.js — it wraps Sanity Client with conventions for data fetching, caching, and live content updates so you're not wiring all of that together yourself.
Step 3: Create a Post Schema
Schemas in Sanity define what content editors are allowed to create in Studio — think of them as the shape of your content, similar to a database model.
Create a new schema file:
// src/sanity/schemaTypes/post.ts
import { defineField, defineType } from "sanity";
export const postSchema = defineType({
name: "post",
title: "Blog 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" }, // auto-generates slug from title
validation: (Rule) => Rule.required(),
}),
defineField({
name: "excerpt",
title: "Excerpt",
type: "text",
rows: 3,
}),
defineField({
name: "coverImage",
title: "Cover Image",
type: "image",
options: { hotspot: true }, // lets editors set a focal point
}),
defineField({
name: "publishedAt",
title: "Published At",
type: "datetime",
}),
defineField({
name: "body",
title: "Body",
type: "array",
of: [{ type: "block" }, { type: "image" }], // rich text + inline images
}),
],
orderings: [
{
title: "Published Date, New",
name: "publishedAtDesc",
by: [{ field: "publishedAt", direction: "desc" }],
},
],
});
A quick breakdown of what's happening:
defineTyperegisters a new document type — editors will see "Blog Post" as a creatable content type in StudiodefineFielddescribes each individual field and its validation rules- The
slugfield withoptions: { source: "title" }auto-generates a URL-friendly slug from the title as the editor types bodyusestype: "array"withblockandimage— this is Sanity's Portable Text format, a structured way of representing rich text that isn't tied to HTML
💡 Tip:
Rule.required()enforces validation inside Studio itself. Editors literally cannot publish a post without a title or slug — no backend validation needed.
Step 4: Register the Schema
Defining a schema isn't enough — Studio needs to know it exists. Register it in the schema index:
// src/sanity/schemaTypes/index.ts
import { type SchemaTypeDefinition } from "sanity";
import { postSchema } from "./post";
export const schema: { types: SchemaTypeDefinition[] } = {
types: [postSchema],
};
⚠️ Common Mistake: Forgetting this step is the #1 reason a new schema "doesn't show up" in Studio. Defining the type isn't enough — it has to be added to this
typesarray.
Now start your dev server and visit http://localhost:3000/studio. You should see "Blog Post" listed as a content type, ready to create your first post.
Step 5: Write GROQ Queries
GROQ (Graph-Relational Object Queries) is Sanity's query language, purpose-built for querying structured, nested JSON-like content.
💡 Tip: If you've used SQL or MongoDB queries before, GROQ will feel different at first, but it's actually closer to JavaScript object/array syntax than SQL.
Create a queries file:
// src/sanity/queries.ts
import { groq } from "next-sanity";
// Fetch all posts for the blog listing page
export const allPostsQuery = groq`
*[_type == "post"] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
publishedAt,
coverImage {
asset-> { _id, url }
}
}
`;
// Fetch a single post by slug
export const postBySlugQuery = groq`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
excerpt,
publishedAt,
coverImage {
asset-> { _id, url }
},
body
}
`;
// Fetch all slugs for generateStaticParams
export const allPostSlugsQuery = groq`
*[_type == "post"] { "slug": slug.current }
`;
Here's what each part of the first query means:
*[_type == "post"]— selects every document where the type equals"post"| order(publishedAt desc)— sorts results by publish date, newest first{ ... }— a projection, meaning "only return these fields," similar to selecting specific columns in SQLcoverImage { asset-> { _id, url } }— the->is a dereference, following the image asset reference to pull its actual URL
The second query uses $slug as a parameter and [0] to grab just the first matching result, since slugs are unique.
Step 6: Add TypeScript Types
Since GROQ queries return plain JSON, TypeScript has no way to infer their shape automatically. Define the types yourself so the rest of your app stays type-safe:
// src/sanity/types.ts
export interface SanitySlug {
current: string;
}
export interface SanityImageAsset {
_id: string;
url: string;
}
export interface SanityImage {
asset: SanityImageAsset;
}
export interface Post {
_id: string;
title: string;
slug: SanitySlug;
excerpt?: string;
publishedAt: string;
coverImage?: SanityImage;
body?: unknown[]; // Portable Text blocks
}
💡 Tip: Sanity also offers a code generation tool (
sanity typegen) that can generate these types automatically from your schema and queries. Writing them by hand works fine for smaller schemas, but it's worth exploring once your content model grows.
Step 7: Build the Blog Listing Page
With queries and types ready, fetching content becomes straightforward:
// app/blog/page.tsx
import { sanityFetch } from "@/sanity/lib/live";
import { allPostsQuery } from "@/sanity/queries";
import { Post } from "@/sanity/types";
import Link from "next/link";
export default async function BlogPage() {
const { data: posts } = await sanityFetch({ query: allPostsQuery });
return (
<main className="max-w-3xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<ul className="space-y-8">
{(posts as Post[]).map((post) => (
<li key={post._id} className="border-b pb-8">
<Link href={`/blog/${post.slug.current}`}>
<h2 className="text-2xl font-semibold hover:underline">
{post.title}
</h2>
</Link>
{post.excerpt && (
<p className="mt-2 text-gray-600">{post.excerpt}</p>
)}
{post.publishedAt && (
<time className="text-sm text-gray-400">
{new Date(post.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
)}
</li>
))}
</ul>
</main>
);
}
Notice we're using sanityFetch from @/sanity/lib/live instead of calling the Sanity client directly. That's intentional — sanityFetch is a wrapper that ties into Next.js's caching and revalidation system, and it's also what enables the live-update behavior in the next step.
Step 8: Enable Sanity Live in the Root Layout
SanityLive is a component that subscribes to Sanity's Live Content API, so changes you publish in Studio show up on your site without a manual rebuild or redeploy.
// app/layout.tsx
import { SanityLive } from "@/sanity/lib/live";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
{children}
<SanityLive />
</body>
</html>
);
}
SanityLive doesn't render any visible UI — it just sits in the background and triggers revalidation whenever content changes in the Content Lake.
⚠️ Common Mistake: Placing
SanityLiveonly on specific pages instead of the root layout. Since it needs to listen for changes globally, the root layout is the correct place for it.
Step 9: Style Rich Text with Tailwind Typography
Portable Text renders as plain HTML elements with no default styling, so headings, paragraphs, and lists will look unstyled out of the box. The Tailwind Typography plugin fixes this in one line.
Install it:
npm install @tailwindcss/typography
Add the plugin to your global CSS:
/* app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
You'll use the prose class on any container wrapping Portable Text content in the next step.
Step 10: Build the Dynamic Post Page
This is where everything comes together — fetching a single post by slug, pre-rendering it at build time, and rendering its rich text body with PortableText.
// app/blog/[slug]/page.tsx
import { sanityFetch } from "@/sanity/lib/live";
import { allPostSlugsQuery, postBySlugQuery } from "@/sanity/queries";
import { Post } from "@/sanity/types";
import { PortableText } from "next-sanity";
import Image from "next/image";
import { notFound } from "next/navigation";
// Tell Next.js which slugs to pre-render at build time
export async function generateStaticParams() {
const { data: slugs } = await sanityFetch({ query: allPostSlugsQuery });
return (slugs as { slug: string }[]).map(({ slug }) => ({ slug }));
}
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const { data } = await sanityFetch({ query: postBySlugQuery, params: { slug } });
if (!data) notFound(); // renders your app/not-found.tsx
const post = data as Post;
return (
<article className="max-w-3xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
{post.publishedAt && (
<time className="text-sm text-gray-400 block mb-8">
{new Date(post.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
)}
{post.coverImage?.asset?.url && (
<div className="relative mb-8 rounded-xl overflow-hidden">
<Image
src={post.coverImage.asset.url}
alt={post.title}
className="rounded-xl"
priority
width={1200}
height={630}
/>
</div>
)}
{Array.isArray(post.body) && (
<div className="prose prose-lg max-w-none">
<PortableText value={post.body} />
</div>
)}
</article>
);
}
A couple of things worth calling out:
generateStaticParamsruns at build time and tells Next.js exactly which/blog/[slug]pages to statically generate, rather than rendering every post on demandparamsis passed as aPromise— this matches Next.js 16's async params convention, which is whypostBySlugQueryaccepts it directly insidesanityFetchnotFound()triggers your customnot-found.tsxpage if no post matches the slug, instead of crashing with an unhandled error
⚠️ Common Mistake: Forgetting
Array.isArray(post.body)before renderingPortableText. Ifbodyis everundefined(e.g. an editor saved a post without adding body content),PortableTextwill throw instead of failing gracefully.
Frequently Asked Questions
Why GROQ instead of a REST or GraphQL API?
GROQ is built specifically for querying deeply nested, structured content in a single request — including resolving references like images — without the boilerplate of a separate GraphQL schema or multiple REST calls.
Do I need to deploy Sanity Studio separately?
No. Since Studio is embedded inside your Next.js app at /studio, it deploys automatically whenever your Next.js app deploys — no separate hosting or build step required.
How do I render images from Sanity with Next.js Image optimization?
Use @sanity/image-url to build the image URL, then pass it to Next.js <Image>. Add cdn.sanity.io to the images.remotePatterns array in next.config.ts so Next.js is allowed to optimize it:
// next.config.ts
images: {
remotePatterns: [{ protocol: "https", hostname: "cdn.sanity.io" }],
}
Is SanityLive safe to use in production?
Yes. It's designed for production use and is the recommended way to get real-time content updates without manually managing webhooks or polling.
Is the Sanity Studio accessible to the public?
By default, yes — anyone who visits /studio can see it (though they can't edit without a Sanity account with the right permissions). To fully restrict it, wrap the studio route in a middleware auth check or only run it in dev using an environment variable guard.
How is this different from my current MDX-based blog?
With MDX, content lives as files in your repo — every edit means a commit and redeploy. With Sanity, content lives in the cloud and can be edited through a UI, published instantly, and even handed off to non-technical writers without giving them repo access.
Can I add more content types later, like authors or categories?
Yes. Create a new schema file the same way you created post.ts, then register it in the types array inside schemaTypes/index.ts. Studio will pick it up immediately.
Conclusion
You now have a fully working Sanity CMS integration inside a Next.js 16 application — complete with an embedded Studio, a typed content model, GROQ-powered queries, and live content updates. From here, you can expand your schema with authors, categories, or custom block content, add Visual Editing for inline previews, or wire up on-demand revalidation through Sanity's webhooks.
This setup scales well beyond a simple blog — the same pattern works for landing pages, product catalogs, or any structured content your app needs to manage outside the codebase.
Useful Resources
- Sanity: Create a New Sanity Project
- Sanity: The next-sanity Toolkit
- Sanity GROQ Documentation
- Portable Text Documentation
Continue Learning
If you'd like to keep building on this foundation, check out these related guides:
- SaaS Starter Architecture with Next.js 2026
- Build a Todo App with Next.js 16 and Supabase (2026 Guide)
📦 Source Code: View on GitHub