
Building a Blog With Next.js and Sanity
To build a blog with Next.js and Sanity, model post, author and category document types in Sanity Studio, write GROQ queries for the post list, a single post and the list of slugs, and fetch them in App Router Server Components with a next-sanity client. Use generateStaticParams to prerender every post, generateMetadata for titles and descriptions, @portabletext/react for the body and @sanity/image-url for images. Tag your fetches and call revalidateTag() from a Sanity webhook so new posts appear seconds after editors publish, without a full rebuild.
A blog is the classic first Sanity project because it touches every important piece: references, rich text, images, dynamic routes, SEO and cache invalidation. This guide builds one from end to end. It assumes you've already connected Sanity with Next.js, so you have a project ID, a dataset and a working client.ts. By the end you'll have a home page that lists posts, a page per post, a page per category and an API route that keeps it all fresh.
What You'll Build
The finished blog has four routes and one webhook endpoint:
| Route | Content | Rendering |
|---|---|---|
/ | The latest 12 posts | Static, revalidated on publish |
/posts/[slug] | A single post with author, categories and body | Prerendered for every slug |
/categories/[slug] | Posts in one category | Prerendered for every category |
/api/revalidate | Receives Sanity webhooks | Route handler |
Here's the folder layout we'll end up with:
src/
app/
layout.tsx
page.tsx
posts/[slug]/page.tsx
categories/[slug]/page.tsx
api/revalidate/route.ts
components/
PostCard.tsx
PostBody.tsx
sanity/
env.ts
lib/client.ts
lib/fetch.ts
lib/image.ts
lib/queries.ts
schemaTypes/
author.ts
category.ts
post.ts
index.ts
Step 1: Model the Content
Three document types cover a typical blog. Each uses the typed helpers from sanity, which the guide to defineType and defineField explains in detail.
// src/sanity/schemaTypes/author.ts
import { defineField, defineType } from 'sanity'
export const author = defineType({
name: 'author',
title: 'Author',
type: 'document',
fields: [
defineField({ name: 'name', type: 'string', validation: (rule) => rule.required() }),
defineField({ name: 'slug', type: 'slug', options: { source: 'name' } }),
defineField({ name: 'image', type: 'image', options: { hotspot: true } }),
defineField({ name: 'bio', type: 'text', rows: 3 }),
],
})
// src/sanity/schemaTypes/category.ts
import { defineField, defineType } from 'sanity'
export const category = defineType({
name: 'category',
title: 'Category',
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: 'description', type: 'text', rows: 2 }),
],
})
The post ties them together with references and a Portable Text body:
// src/sanity/schemaTypes/post.ts
import { defineArrayMember, defineField, defineType } from 'sanity'
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string', validation: (rule) => rule.required() }),
defineField({
name: 'slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: (rule) => rule.required(),
}),
defineField({ name: 'excerpt', type: 'text', rows: 3, validation: (rule) => rule.max(200) }),
defineField({ name: 'author', type: 'reference', to: [{ type: 'author' }] }),
defineField({
name: 'categories',
type: 'array',
of: [defineArrayMember({ type: 'reference', to: [{ type: 'category' }] })],
}),
defineField({
name: 'mainImage',
type: 'image',
options: { hotspot: true },
fields: [defineField({ name: 'alt', type: 'string', title: 'Alternative text' })],
}),
defineField({ name: 'publishedAt', type: 'datetime', initialValue: () => new Date().toISOString() }),
defineField({
name: 'body',
type: 'array',
of: [
defineArrayMember({ type: 'block' }),
defineArrayMember({
type: 'image',
options: { hotspot: true },
fields: [defineField({ name: 'alt', type: 'string', title: 'Alternative text' })],
}),
],
}),
],
preview: {
select: { title: 'title', author: 'author.name', media: 'mainImage' },
prepare: ({ title, author, media }) => ({ title, subtitle: author && `by ${author}`, media }),
},
})
// src/sanity/schemaTypes/index.ts
import { author } from './author'
import { category } from './category'
import { post } from './post'
export const schemaTypes = [post, author, category]
Add schemaTypes to schema.types in your sanity.config.ts, open the Studio and create an author, two categories and a couple of posts so there's something to render. If you want stricter rules, such as unique slugs or required alt text, see custom slugs and validation rules.
Step 2: Write the Queries
Keep all queries in one file and wrap them in defineQuery() so TypeGen can type the results. Projections shape the data exactly as the components need it, and -> follows references:
// src/sanity/lib/queries.ts
import { defineQuery } from 'next-sanity'
const postCardFields = `
_id,
title,
"slug": slug.current,
excerpt,
publishedAt,
mainImage,
"author": author->name
`
export const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]
| order(publishedAt desc)[0...12]{ ${postCardFields} }`)
export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
${postCardFields},
body,
"categories": categories[]->{ title, "slug": slug.current },
"authorBio": author->bio
}`)
export const POST_SLUGS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]{
"slug": slug.current
}`)
export const CATEGORY_QUERY = defineQuery(`*[_type == "category" && slug.current == $slug][0]{
title,
description,
"posts": *[_type == "post" && references(^._id)] | order(publishedAt desc){ ${postCardFields} }
}`)
export const CATEGORY_SLUGS_QUERY = defineQuery(`*[_type == "category" && defined(slug.current)]{
"slug": slug.current
}`)
The category query uses references(^._id) to find every post pointing at the current category, a pattern covered in dereferencing in GROQ. One caveat: TypeGen can't follow the string interpolation of postCardFields, so if you rely on generated types, write each projection out in full instead.
Step 3: Add a Fetch Helper With Cache Tags
You want blog pages cached until content changes, not refetched on every request. A small wrapper around client.fetch sets Next.js cache tags on every query:
// src/sanity/lib/fetch.ts
import type { QueryParams } from 'next-sanity'
import { client } from './client'
export async function sanityFetch<const QueryString extends string>({
query,
params = {},
tags,
}: {
query: QueryString
params?: QueryParams
tags: string[]
}) {
return client.fetch(query, params, {
next: { revalidate: false, tags },
})
}
With revalidate: false, results stay cached until something calls revalidateTag() for one of their tags, which the webhook in Step 7 will do. Tag queries by the document types they depend on, so a change to an author refreshes every page showing that author's name.
This is the manual approach. If you'd rather have Sanity manage tags for you, use defineLive instead, as described in using Sanity with the Next.js App Router. Don't mix the two: they both export a function called sanityFetch with different signatures.
Step 4: Build the Home Page
First a reusable card. It imports the urlFor helper built on createImageUrlBuilder:
// src/components/PostCard.tsx
import Image from 'next/image'
import Link from 'next/link'
import type { SanityImageSource } from '@sanity/image-url'
import { urlFor } from '@/sanity/lib/image'
type Props = {
title: string | null
slug: string | null
excerpt: string | null
publishedAt: string | null
author: string | null
mainImage: (SanityImageSource & { alt?: string }) | null
}
export function PostCard({ title, slug, excerpt, publishedAt, author, mainImage }: Props) {
return (
<article className="space-y-3">
{mainImage && (
<Image
src={urlFor(mainImage).width(800).height(450).url()}
alt={mainImage.alt ?? ''}
width={800}
height={450}
className="rounded-lg"
/>
)}
<h2 className="text-2xl font-semibold">
<Link href={`/posts/${slug}`}>{title}</Link>
</h2>
<p className="text-sm text-gray-500">
{author}
{publishedAt && ` · ${new Date(publishedAt).toLocaleDateString('en-GB')}`}
</p>
{excerpt && <p>{excerpt}</p>}
</article>
)
}
Then the page itself:
// src/app/page.tsx
import { PostCard } from '@/components/PostCard'
import { sanityFetch } from '@/sanity/lib/fetch'
import { POSTS_QUERY } from '@/sanity/lib/queries'
export default async function HomePage() {
const posts = await sanityFetch({ query: POSTS_QUERY, tags: ['post', 'author'] })
return (
<main className="mx-auto grid max-w-5xl gap-12 p-8 md:grid-cols-2">
{posts.map((post) => (
<PostCard key={post._id} {...post} />
))}
</main>
)
}
Twelve posts is enough for a start. When you outgrow it, pagination with GROQ shows how to add pages with stable ordering.
Step 5: Build the Post Page
The post page has three jobs: tell Next.js which slugs exist, generate metadata and render the post. In Next.js 16, params is a Promise, so you await it:
// src/app/posts/[slug]/page.tsx
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { PostBody } from '@/components/PostBody'
import { client } from '@/sanity/lib/client'
import { sanityFetch } from '@/sanity/lib/fetch'
import { urlFor } from '@/sanity/lib/image'
import { POST_QUERY, POST_SLUGS_QUERY } from '@/sanity/lib/queries'
type Props = { params: Promise<{ slug: string }> }
export async function generateStaticParams() {
return client.withConfig({ useCdn: false }).fetch(POST_SLUGS_QUERY)
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await sanityFetch({ query: POST_QUERY, params: { slug }, tags: ['post'] })
if (!post) return {}
return {
title: post.title,
description: post.excerpt,
openGraph: post.mainImage
? { images: [{ url: urlFor(post.mainImage).width(1200).height(630).url() }] }
: undefined,
}
}
export default async function PostPage({ params }: Props) {
const { slug } = await params
const post = await sanityFetch({
query: POST_QUERY,
params: { slug },
tags: ['post', 'author', 'category'],
})
if (!post) notFound()
return (
<article className="mx-auto max-w-3xl space-y-6 p-8">
<h1 className="text-4xl font-bold">{post.title}</h1>
<p className="text-gray-500">By {post.author}</p>
{post.mainImage && (
<Image
src={urlFor(post.mainImage).width(1600).height(900).url()}
alt={post.mainImage.alt ?? ''}
width={1600}
height={900}
preload
/>
)}
{post.body && <PostBody value={post.body} />}
<ul className="flex gap-3">
{post.categories?.map((category) => (
<li key={category.slug}>
<Link href={`/categories/${category.slug}`}>{category.title}</Link>
</li>
))}
</ul>
</article>
)
}
Some points worth noting:
- generateStaticParams uses useCdn: false: the build must see every published slug, not a cached list.
- Unknown slugs still work: by default Next.js renders slugs that weren't prerendered on first request, so a post published after the build doesn't 404.
- notFound() for missing posts: if a slug has been deleted or unpublished, the visitor gets your 404 page.
- Next.js deduplicates the request:
generateMetadataand the page call the same query with the same params, and the cached result is shared.
For a fuller treatment of metadata fields, see managing SEO metadata in Sanity.
Step 6: Render the Body
Portable Text needs a renderer. Install @portabletext/react and map Sanity images to next/image:
npm install @portabletext/react
// src/components/PostBody.tsx
import Image from 'next/image'
import { PortableText, type PortableTextComponents, type PortableTextProps } from '@portabletext/react'
import { urlFor } from '@/sanity/lib/image'
const components: PortableTextComponents = {
types: {
image: ({ value }) => (
<Image
src={urlFor(value).width(1200).url()}
alt={value.alt ?? ''}
width={1200}
height={675}
className="rounded-lg"
/>
),
},
}
export function PostBody({ value }: { value: PortableTextProps['value'] }) {
return (
<div className="prose lg:prose-lg">
<PortableText value={value} components={components} />
</div>
)
}
The prose classes come from @tailwindcss/typography. To go further, the guides on rendering Portable Text in React and styling Portable Text with Tailwind cover custom blocks, links and heading anchors.
Step 7: Revalidate When Editors Publish
Everything is cached with tags, so the final piece is a route that receives a Sanity webhook and invalidates the right tag. parseBody from next-sanity/webhook verifies the signature and, with its third argument set to true, waits briefly so the Content Lake is consistent before you refetch:
// src/app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
import { parseBody } from 'next-sanity/webhook'
type WebhookPayload = { _type: string; slug?: string }
export async function POST(req: NextRequest) {
try {
const { isValidSignature, body } = await parseBody<WebhookPayload>(
req,
process.env.SANITY_REVALIDATE_SECRET,
true,
)
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 })
}
if (!body?._type) {
return new Response('Bad request', { status: 400 })
}
revalidateTag(body._type, 'max')
return NextResponse.json({ revalidated: body._type, slug: body.slug })
} catch (error) {
console.error(error)
return new Response((error as Error).message, { status: 500 })
}
}
In Next.js 16, revalidateTag takes a second argument. 'max' marks the tag as stale, so the next visitor triggers a background refresh while still getting a fast response.
Now create the webhook at sanity.io/manage under API > Webhooks:
- Set the URL to
https://your-site.com/api/revalidate. - Choose the dataset and trigger on create, update and delete.
- Set the filter to
_type in ["post", "author", "category"]. - Set the projection to
{_type, "slug": slug.current}. - Add a secret, and set the same value as
SANITY_REVALIDATE_SECRETin your hosting environment.
Publish a post and it appears on the site within seconds. The guide to Sanity webhooks covers payloads, filters and retries in more depth.
Step 8: Add Category Pages
Category pages reuse the same pieces:
// src/app/categories/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { PostCard } from '@/components/PostCard'
import { client } from '@/sanity/lib/client'
import { sanityFetch } from '@/sanity/lib/fetch'
import { CATEGORY_QUERY, CATEGORY_SLUGS_QUERY } from '@/sanity/lib/queries'
type Props = { params: Promise<{ slug: string }> }
export async function generateStaticParams() {
return client.withConfig({ useCdn: false }).fetch(CATEGORY_SLUGS_QUERY)
}
export default async function CategoryPage({ params }: Props) {
const { slug } = await params
const category = await sanityFetch({
query: CATEGORY_QUERY,
params: { slug },
tags: ['category', 'post'],
})
if (!category) notFound()
return (
<main className="mx-auto max-w-5xl space-y-8 p-8">
<h1 className="text-4xl font-bold">{category.title}</h1>
{category.description && <p>{category.description}</p>}
<div className="grid gap-12 md:grid-cols-2">
{category.posts.map((post) => (
<PostCard key={post._id} {...post} />
))}
</div>
</main>
)
}
Because the query is tagged with both category and post, publishing a new post in that category refreshes the page too.
Step 9: Deploy
Deploy the Next.js app to Vercel, Netlify or any Node host, then finish the setup:
- Environment variables: add
NEXT_PUBLIC_SANITY_PROJECT_ID,NEXT_PUBLIC_SANITY_DATASETandSANITY_REVALIDATE_SECRETin the hosting dashboard. - Webhook URL: point the webhook at the production domain, not a preview deployment.
- CORS: add the production origin with
npx sanity cors add https://your-site.com --credentialsif you embed the Studio or use live features. - Studio: deploy it separately with
npx sanity deploy, or embed it at/studio, as covered in deploying Sanity Studio.
FAQ: Next.js and Sanity Blog
No. Pages are cached with tags, and the webhook calls revalidateTag when content changes, so only the affected data is refreshed. New slugs render on first request because the dynamic route allows slugs that weren't prerendered at build time.
Both work. The manual helper with tags and a webhook gives you predictable, explicit control and suits mostly static blogs. defineLive manages tags for you and adds live updates and draft previews, which is what Sanity recommends for most apps. Pick one per project, because both are called sanityFetch.
Use Next.js Draft Mode with a Viewer token and the drafts perspective. The simplest complete setup is Sanity's Presentation tool with Visual Editing, which handles enabling Draft Mode securely from the Studio.
Create a route handler or a sitemap.ts file that fetches all post slugs and dates with GROQ and returns the XML. Tag the fetch with post so the webhook refreshes it along with the rest of the site.
Check that the post is published, has a slug and matches the query filter. If the slug is correct, check that dynamicParams hasn't been set to false on the route, which limits the route to slugs returned at build time.
You can store Markdown in a text field with a Markdown plugin and render it with a Markdown library, but you lose structured blocks such as images with alt text and references. Portable Text is the better default for a Sanity blog.
Conclusion
A Next.js and Sanity blog comes together from a few well-defined parts: three document types connected by references, a handful of GROQ queries that project exactly the fields each page needs, App Router pages that prerender with generateStaticParams and a Portable Text renderer for the body. Tagging every fetch and pointing a Sanity webhook at a revalidateTag() route keeps the static pages fresh without full rebuilds.
From here you can grow it in whichever direction your editors need. Add Visual Editing so they can click to edit on the page, embed the Studio in the same app, or add search, pagination and richer content blocks as the blog grows.


