Building a Modern Website with Sanity v3 and Next.js App Router

Sanity v3 is a composable content platform that gives you a fully customizable Studio, a real-time content lake, and GROQ — one of the most powerful content query languages available. In this tutorial, you will build a complete blog with live preview, image optimization, and a custom editing experience.
What You Will Build
A DevJournal — a developer blog platform with:
- Sanity Studio v3 embedded inside your Next.js app
- Custom document schemas for Posts, Authors, and Categories
- GROQ queries for flexible content fetching
- Live preview that shows draft changes in real time
- Sanity Image with automatic responsive image optimization
- Portable Text rendering for rich content
- Visual editing with click-to-edit functionality
- On-demand revalidation via webhooks
- SEO metadata and Open Graph image generation
- Tailwind CSS for styling
- Production deployment on Vercel
Prerequisites
Before getting started, make sure you have:
- Node.js 18+ installed
- npm or pnpm as your package manager
- Basic knowledge of React, TypeScript, and Next.js App Router
- A Sanity.io account (free tier available at sanity.io)
- A code editor (VS Code recommended)
- Familiarity with content management concepts
Why Sanity v3?
Sanity takes a fundamentally different approach to content management. Instead of storing content in a traditional database, it uses a real-time content lake — a cloud-hosted, schema-less data store that syncs in real time.
| Feature | Sanity v3 | Strapi 5 | Payload CMS 3 | Contentful |
|---|---|---|---|---|
| Architecture | Hosted content lake | Self-hosted server | Inside Next.js | Hosted SaaS |
| Query language | GROQ (custom) | REST / GraphQL | REST / GraphQL | REST / GraphQL |
| Studio | Fully customizable React | Admin panel | Next.js admin | Cloud dashboard |
| Real-time | Built-in listeners | Requires setup | Requires setup | Webhooks only |
| Image pipeline | Built-in CDN + transforms | Plugin required | Upload adapter | Built-in CDN |
| Pricing | Generous free tier | Free (self-hosted) | Free (self-hosted) | Limited free tier |
| Portable Text | Native rich text format | Blocks / Markdown | Lexical editor | Rich text JSON |
| Live preview | First-class support | Community plugin | Built-in | Limited |
The key advantages of Sanity: real-time collaboration, an incredibly flexible GROQ query language, and a Studio that is 100% customizable since it is just a React application.
Step 1: Create the Next.js Project
Start by creating a new Next.js application:
npx create-next-app@latest devjournal --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd devjournalStep 2: Install Sanity Dependencies
Install the Sanity client, Studio, and related packages:
npm install next-sanity @sanity/image-url @sanity/vision @portabletext/react
npm install -D @sanity/typesThe next-sanity package is the official integration that provides:
- Sanity client configured for Next.js
- Live preview utilities
- Image URL builder
- Studio embedding helpers
Step 3: Create a Sanity Project
If you do not already have a Sanity project, create one:
npx sanity@latest init --envWhen prompted:
- Project name:
devjournal - Use the default dataset configuration? Yes
- Project output path: Choose your current directory
- Select project template: Clean project with no predefined schemas
This creates a .env.local file with your project credentials:
NEXT_PUBLIC_SANITY_PROJECT_ID="your-project-id"
NEXT_PUBLIC_SANITY_DATASET="production"
SANITY_API_READ_TOKEN="your-read-token"You can also find your project ID in the Sanity dashboard at sanity.io/manage.
Step 4: Configure the Sanity Client
Create the Sanity configuration files:
// src/sanity/config.ts
export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!
export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET!
export const apiVersion = '2026-04-07'Now create the Sanity client:
// src/sanity/client.ts
import { createClient } from 'next-sanity'
import { projectId, dataset, apiVersion } from './config'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
})
// Preview client with token for draft content
export const previewClient = createClient({
projectId,
dataset,
apiVersion,
useCdn: false,
token: process.env.SANITY_API_READ_TOKEN,
perspective: 'previewDrafts',
})
export function getClient(preview = false) {
return preview ? previewClient : client
}Step 5: Define Content Schemas
Sanity schemas define the structure of your content. Create schemas for posts, authors, and categories.
Author Schema
// src/sanity/schemas/author.ts
import { defineField, defineType } from 'sanity'
export const author = defineType({
name: 'author',
title: 'Author',
type: 'document',
fields: [
defineField({
name: 'name',
title: 'Name',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'name',
maxLength: 96,
},
validation: (rule) => rule.required(),
}),
defineField({
name: 'image',
title: 'Image',
type: 'image',
options: { hotspot: true },
}),
defineField({
name: 'bio',
title: 'Bio',
type: 'array',
of: [{ type: 'block' }],
}),
],
preview: {
select: {
title: 'name',
media: 'image',
},
},
})Category Schema
// src/sanity/schemas/category.ts
import { defineField, defineType } from 'sanity'
export const category = defineType({
name: 'category',
title: 'Category',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: (rule) => rule.required(),
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
}),
],
})Post Schema
// src/sanity/schemas/post.ts
import { defineField, defineType } from 'sanity'
export const post = 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',
maxLength: 96,
},
validation: (rule) => rule.required(),
}),
defineField({
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }],
validation: (rule) => rule.required(),
}),
defineField({
name: 'mainImage',
title: 'Main Image',
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
description: 'Important for SEO and accessibility',
},
],
}),
defineField({
name: 'categories',
title: 'Categories',
type: 'array',
of: [{ type: 'reference', to: [{ type: 'category' }] }],
}),
defineField({
name: 'publishedAt',
title: 'Published at',
type: 'datetime',
}),
defineField({
name: 'excerpt',
title: 'Excerpt',
type: 'text',
rows: 3,
description: 'Short summary for previews and SEO',
}),
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
{ type: 'block' },
{
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative Text',
},
{
name: 'caption',
type: 'string',
title: 'Caption',
},
],
},
{
type: 'code',
title: 'Code Block',
options: {
language: 'typescript',
languageAlternatives: [
{ title: 'TypeScript', value: 'typescript' },
{ title: 'JavaScript', value: 'javascript' },
{ title: 'CSS', value: 'css' },
{ title: 'HTML', value: 'html' },
{ title: 'Bash', value: 'bash' },
{ title: 'JSON', value: 'json' },
],
},
},
],
}),
defineField({
name: 'seo',
title: 'SEO',
type: 'object',
fields: [
{ name: 'metaTitle', type: 'string', title: 'Meta Title' },
{ name: 'metaDescription', type: 'text', title: 'Meta Description', rows: 3 },
{ name: 'ogImage', type: 'image', title: 'Open Graph Image' },
],
}),
],
preview: {
select: {
title: 'title',
author: 'author.name',
media: 'mainImage',
},
prepare(selection) {
const { author } = selection
return { ...selection, subtitle: author ? `by ${author}` : '' }
},
},
orderings: [
{
title: 'Published Date, New',
name: 'publishedAtDesc',
by: [{ field: 'publishedAt', direction: 'desc' }],
},
],
})Schema Index
// src/sanity/schemas/index.ts
import { author } from './author'
import { category } from './category'
import { post } from './post'
export const schemaTypes = [author, category, post]Step 6: Configure Sanity Studio
Create the Studio configuration file:
// src/sanity/studio.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './schemas'
import { projectId, dataset } from './config'
export default defineConfig({
name: 'devjournal-studio',
title: 'DevJournal Studio',
projectId,
dataset,
plugins: [structureTool(), visionTool()],
schema: {
types: schemaTypes,
},
})Step 7: Embed the Studio in Next.js
Create a route for the Sanity Studio inside your Next.js app:
// src/app/studio/[[...tool]]/page.tsx
'use client'
import { NextStudio } from 'next-sanity/studio'
import config from '@/sanity/studio'
export default function StudioPage() {
return <NextStudio config={config} />
}Add a layout file to prevent the Studio from being affected by your app layout:
// src/app/studio/[[...tool]]/layout.tsx
export const metadata = {
title: 'DevJournal Studio',
}
export default function StudioLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}Now visit http://localhost:3000/studio to access your Sanity Studio. You will see the full editing interface with your custom schemas.
Step 8: Write GROQ Queries
GROQ (Graph-Relational Object Queries) is Sanity's query language. It is incredibly powerful for fetching exactly the data you need.
Create a queries file:
// src/sanity/queries.ts
import { groq } from 'next-sanity'
// Get all published posts
export const postsQuery = groq`
*[_type == "post" && defined(publishedAt)] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
publishedAt,
mainImage,
"author": author->{name, slug, image},
"categories": categories[]->{ title, slug }
}
`
// Get a single post by slug
export const postBySlugQuery = groq`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
excerpt,
publishedAt,
mainImage,
body,
"author": author->{name, slug, image, bio},
"categories": categories[]->{ title, slug },
seo
}
`
// Get all post slugs for static generation
export const postSlugsQuery = groq`
*[_type == "post" && defined(slug.current)][].slug.current
`
// Get posts by category
export const postsByCategoryQuery = groq`
*[_type == "post" && $categorySlug in categories[]->slug.current] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
publishedAt,
mainImage,
"author": author->{name, slug, image},
"categories": categories[]->{ title, slug }
}
`
// Get all categories
export const categoriesQuery = groq`
*[_type == "category"] | order(title asc) {
_id,
title,
slug,
description,
"postCount": count(*[_type == "post" && references(^._id)])
}
`
// Related posts (same category, exclude current)
export const relatedPostsQuery = groq`
*[_type == "post" && _id != $currentId && count(categories[@._ref in $categoryIds]) > 0] | order(publishedAt desc) [0...3] {
_id,
title,
slug,
excerpt,
publishedAt,
mainImage
}
`Notice how GROQ uses the -> operator to dereference references inline. This is one of its most powerful features — you can join related documents without writing complex queries.
Step 9: Set Up Image Handling
Sanity provides a powerful image pipeline with automatic resizing, cropping, and format conversion. Create an image utility:
// src/sanity/image.ts
import createImageUrlBuilder from '@sanity/image-url'
import { projectId, dataset } from './config'
import type { Image } from 'sanity'
const imageBuilder = createImageUrlBuilder({ projectId, dataset })
export function urlForImage(source: Image | undefined) {
if (!source) return undefined
return imageBuilder.image(source).auto('format').fit('max')
}
// Helper for responsive images
export function getImageDimensions(image: Image) {
if (!image?.asset?._ref) return { width: 0, height: 0, aspectRatio: 1 }
const [, , dimensions] = image.asset._ref.split('-')
const [width, height] = dimensions.split('x').map(Number)
return {
width,
height,
aspectRatio: width / height,
}
}Create a reusable image component:
// src/components/sanity-image.tsx
import Image from 'next/image'
import { urlForImage, getImageDimensions } from '@/sanity/image'
import type { Image as SanityImageType } from 'sanity'
interface SanityImageProps {
image: SanityImageType & { alt?: string }
width?: number
height?: number
className?: string
priority?: boolean
}
export function SanityImage({
image,
width,
height,
className,
priority = false,
}: SanityImageProps) {
const imageUrl = urlForImage(image)?.url()
if (!imageUrl) return null
const dimensions = getImageDimensions(image)
return (
<Image
src={imageUrl}
alt={image.alt || ''}
width={width || dimensions.width}
height={height || dimensions.height}
className={className}
priority={priority}
placeholder="blur"
blurDataURL={
urlForImage(image)?.width(24).height(24).blur(10).url() || undefined
}
/>
)
}Step 10: Build the Blog Pages
Blog Listing Page
// src/app/blog/page.tsx
import Link from 'next/link'
import { client } from '@/sanity/client'
import { postsQuery } from '@/sanity/queries'
import { SanityImage } from '@/components/sanity-image'
import { formatDate } from '@/lib/utils'
export const revalidate = 60 // Revalidate every 60 seconds
export default async function BlogPage() {
const posts = await client.fetch(postsQuery)
return (
<main className="mx-auto max-w-6xl px-4 py-16">
<h1 className="mb-12 text-4xl font-bold">DevJournal Blog</h1>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post: any) => (
<article
key={post._id}
className="group overflow-hidden rounded-xl border bg-white shadow-sm transition-shadow hover:shadow-md"
>
<Link href={`/blog/${post.slug.current}`}>
{post.mainImage && (
<SanityImage
image={post.mainImage}
width={600}
height={340}
className="aspect-video w-full object-cover transition-transform group-hover:scale-105"
/>
)}
<div className="p-6">
<div className="mb-2 flex gap-2">
{post.categories?.map((cat: any) => (
<span
key={cat.slug.current}
className="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-800"
>
{cat.title}
</span>
))}
</div>
<h2 className="mb-2 text-xl font-semibold">
{post.title}
</h2>
<p className="mb-4 line-clamp-2 text-gray-600">
{post.excerpt}
</p>
<div className="flex items-center gap-3 text-sm text-gray-500">
{post.author?.image && (
<SanityImage
image={post.author.image}
width={32}
height={32}
className="rounded-full"
/>
)}
<span>{post.author?.name}</span>
<span>·</span>
<time>{formatDate(post.publishedAt)}</time>
</div>
</div>
</Link>
</article>
))}
</div>
</main>
)
}Single Post Page
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { client } from '@/sanity/client'
import {
postBySlugQuery,
postSlugsQuery,
relatedPostsQuery,
} from '@/sanity/queries'
import { SanityImage } from '@/components/sanity-image'
import { PortableTextRenderer } from '@/components/portable-text'
import { formatDate } from '@/lib/utils'
import type { Metadata } from 'next'
interface PageProps {
params: Promise<{ slug: string }>
}
// Generate static paths
export async function generateStaticParams() {
const slugs = await client.fetch(postSlugsQuery)
return slugs.map((slug: string) => ({ slug }))
}
// Dynamic metadata for SEO
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const post = await client.fetch(postBySlugQuery, { slug })
if (!post) return { title: 'Post Not Found' }
return {
title: post.seo?.metaTitle || post.title,
description: post.seo?.metaDescription || post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
},
}
}
export default async function PostPage({ params }: PageProps) {
const { slug } = await params
const post = await client.fetch(postBySlugQuery, { slug })
if (!post) notFound()
const categoryIds = post.categories?.map((c: any) => c._id) || []
const relatedPosts = await client.fetch(relatedPostsQuery, {
currentId: post._id,
categoryIds,
})
return (
<article className="mx-auto max-w-3xl px-4 py-16">
{/* Header */}
<header className="mb-12">
<div className="mb-4 flex gap-2">
{post.categories?.map((cat: any) => (
<span
key={cat.slug.current}
className="rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
{cat.title}
</span>
))}
</div>
<h1 className="mb-4 text-4xl font-bold leading-tight lg:text-5xl">
{post.title}
</h1>
{post.excerpt && (
<p className="mb-6 text-xl text-gray-600">{post.excerpt}</p>
)}
<div className="flex items-center gap-4">
{post.author?.image && (
<SanityImage
image={post.author.image}
width={48}
height={48}
className="rounded-full"
/>
)}
<div>
<p className="font-medium">{post.author?.name}</p>
<time className="text-sm text-gray-500">
{formatDate(post.publishedAt)}
</time>
</div>
</div>
</header>
{/* Cover Image */}
{post.mainImage && (
<SanityImage
image={post.mainImage}
className="mb-12 w-full rounded-xl"
priority
/>
)}
{/* Body */}
<div className="prose prose-lg max-w-none">
<PortableTextRenderer value={post.body} />
</div>
{/* Related Posts */}
{relatedPosts.length > 0 && (
<section className="mt-16 border-t pt-12">
<h2 className="mb-8 text-2xl font-bold">Related Posts</h2>
<div className="grid gap-6 sm:grid-cols-3">
{relatedPosts.map((related: any) => (
<a
key={related._id}
href={`/blog/${related.slug.current}`}
className="group rounded-lg border p-4 transition-shadow hover:shadow-md"
>
<h3 className="font-semibold group-hover:text-blue-600">
{related.title}
</h3>
<p className="mt-2 line-clamp-2 text-sm text-gray-600">
{related.excerpt}
</p>
</a>
))}
</div>
</section>
)}
</article>
)
}Step 11: Render Portable Text
Sanity uses Portable Text — a rich text format that gives you full control over rendering. Create a custom renderer:
// src/components/portable-text.tsx
import {
PortableText,
type PortableTextComponents,
} from '@portabletext/react'
import { SanityImage } from './sanity-image'
const components: PortableTextComponents = {
types: {
image: ({ value }) => (
<figure className="my-8">
<SanityImage
image={value}
className="w-full rounded-lg"
/>
{value.caption && (
<figcaption className="mt-2 text-center text-sm text-gray-500">
{value.caption}
</figcaption>
)}
</figure>
),
code: ({ value }) => (
<pre className="my-6 overflow-x-auto rounded-lg bg-gray-900 p-4">
<code className={`language-${value.language || 'text'}`}>
{value.code}
</code>
</pre>
),
},
marks: {
link: ({ children, value }) => {
const rel = value?.href?.startsWith('/')
? undefined
: 'noreferrer noopener'
return (
<a
href={value?.href}
rel={rel}
className="text-blue-600 underline hover:text-blue-800"
>
{children}
</a>
)
},
code: ({ children }) => (
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-sm">
{children}
</code>
),
},
block: {
h2: ({ children }) => (
<h2 className="mb-4 mt-12 text-3xl font-bold">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-3 mt-8 text-2xl font-semibold">{children}</h3>
),
blockquote: ({ children }) => (
<blockquote className="my-6 border-l-4 border-blue-500 pl-4 italic text-gray-700">
{children}
</blockquote>
),
},
}
export function PortableTextRenderer({ value }: { value: any }) {
if (!value) return null
return <PortableText value={value} components={components} />
}Step 12: Add Live Preview
Live preview is one of Sanity's standout features. It lets content editors see their changes in real time before publishing.
Create a Preview Provider
// src/components/preview-provider.tsx
'use client'
import { LiveQueryProvider } from 'next-sanity/preview'
import { client } from '@/sanity/client'
export default function PreviewProvider({
children,
token,
}: {
children: React.ReactNode
token: string
}) {
return (
<LiveQueryProvider client={client} token={token}>
{children}
</LiveQueryProvider>
)
}Create a Preview Route
// src/app/api/preview/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 slug = searchParams.get('slug')
const secret = searchParams.get('secret')
// Validate the secret
if (secret !== process.env.SANITY_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 })
}
const draft = await draftMode()
draft.enable()
redirect(slug ? `/blog/${slug}` : '/blog')
}Exit Preview Route
// src/app/api/exit-preview/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET() {
const draft = await draftMode()
draft.disable()
redirect('/blog')
}Add a preview banner component to indicate when draft mode is active:
// src/components/preview-banner.tsx
import Link from 'next/link'
export function PreviewBanner() {
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-black p-3 text-center text-white">
<span className="mr-4">Preview Mode Active</span>
<Link
href="/api/exit-preview"
className="rounded bg-white px-4 py-1 text-sm font-medium text-black hover:bg-gray-200"
>
Exit Preview
</Link>
</div>
)
}Step 13: On-Demand Revalidation with Webhooks
Instead of revalidating on a timer, set up a webhook so Sanity triggers revalidation when content changes:
// src/app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-sanity-webhook-secret')
if (secret !== process.env.SANITY_WEBHOOK_SECRET) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
// Revalidate specific paths based on document type
switch (body._type) {
case 'post':
revalidatePath('/blog')
if (body.slug?.current) {
revalidatePath(`/blog/${body.slug.current}`)
}
break
case 'author':
revalidatePath('/blog')
break
case 'category':
revalidatePath('/blog')
break
default:
revalidatePath('/')
}
return NextResponse.json({ revalidated: true, now: Date.now() })
}To set up the webhook in Sanity:
- Go to sanity.io/manage and select your project
- Navigate to API then Webhooks
- Click Create Webhook
- Set the URL to
https://your-domain.com/api/revalidate - Add a custom header:
x-sanity-webhook-secretwith your secret value - Select the document types to trigger on (post, author, category)
- Choose the Create, Update, and Delete events
Step 14: Add Utility Functions
Create a utility file for common operations:
// src/lib/utils.ts
export function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function estimateReadingTime(text: string): number {
const wordsPerMinute = 200
const wordCount = text.split(/\s+/).length
return Math.ceil(wordCount / wordsPerMinute)
}Step 15: TypeScript Types from Schemas
Add TypeScript types to get full type safety:
// src/sanity/types.ts
import type { Image, Slug, PortableTextBlock } from 'sanity'
export interface Author {
_id: string
name: string
slug: Slug
image?: Image
bio?: PortableTextBlock[]
}
export interface Category {
_id: string
title: string
slug: Slug
description?: string
postCount?: number
}
export interface Post {
_id: string
title: string
slug: Slug
author: Author
mainImage?: Image & { alt?: string }
categories?: Category[]
publishedAt: string
excerpt?: string
body?: PortableTextBlock[]
seo?: {
metaTitle?: string
metaDescription?: string
ogImage?: Image
}
}Step 16: Add the GROQ Vision Plugin
Sanity Vision is a built-in tool for testing GROQ queries directly in the Studio. You already installed it in Step 2 — it is configured in studio.ts with the visionTool() plugin.
Open the Studio at /studio, click the Vision tab, and try these queries:
// Count all posts
count(*[_type == "post"])
// Find posts missing excerpts
*[_type == "post" && !defined(excerpt)] { title }
// Get all categories with post counts
*[_type == "category"] {
title,
"postCount": count(*[_type == "post" && references(^._id)])
}
// Full-text search
*[_type == "post" && (title match "react*" || excerpt match "react*")] {
title, excerpt
}Step 17: Deploy to Production
Deploy Next.js to Vercel
# Push to GitHub
git add .
git commit -m "feat: complete devjournal with Sanity v3"
git push origin main
# Deploy via Vercel CLI
npx vercelSet environment variables in Vercel:
NEXT_PUBLIC_SANITY_PROJECT_IDNEXT_PUBLIC_SANITY_DATASETSANITY_API_READ_TOKENSANITY_PREVIEW_SECRETSANITY_WEBHOOK_SECRET
Configure CORS in Sanity
Go to sanity.io/manage and add your production domain to the CORS origins:
https://your-domain.com(with credentials allowed)http://localhost:3000(for development)
Testing Your Implementation
- Studio Access: Visit
/studioand verify you can create authors, categories, and posts - Blog Listing: Visit
/blogand confirm posts render with images and metadata - Single Post: Click a post and verify Portable Text renders correctly
- Image Optimization: Check that images load as WebP with responsive sizes
- Live Preview: Enable draft mode and verify changes appear in real time
- Revalidation: Publish a change in the Studio and verify the site updates
- SEO: Inspect page source for correct meta tags and Open Graph data
Troubleshooting
"Project not found" or CORS errors
Make sure your project ID is correct and you have added localhost:3000 to CORS origins in the Sanity dashboard.
Images not loading
Verify that your Sanity dataset is set to public for image assets, or that you are passing the correct token for private datasets.
Studio shows blank page
Check the browser console. Common issues include missing schema types or incorrect plugin configuration. Ensure next-sanity version matches your Sanity version.
GROQ query returns empty
Use the Vision tool in the Studio to test queries. Common mistakes include missing _type filters or incorrect reference syntax.
Draft mode not working
Ensure your SANITY_PREVIEW_SECRET is set in both your .env.local and the preview URL you are using. The draftMode() function requires the App Router.
Next Steps
- Structured content: Add more document types like Projects, Team Members, or FAQ
- Visual editing: Integrate Sanity's Visual Editing for click-to-edit on the frontend
- Internationalization: Use Sanity's document-level i18n plugin for multilingual content
- Search: Implement full-text search using GROQ text matching or Algolia integration
- Analytics: Add view counts using Sanity mutations from the frontend
- Webhooks: Set up Slack notifications when content is published
Conclusion
You have built a complete content-driven website with Sanity v3 and Next.js App Router. The combination gives you a fully customizable CMS Studio, a powerful GROQ query language, real-time live preview, and an optimized image pipeline — all while maintaining full type safety with TypeScript.
Sanity's content lake architecture means your content is always available via API, making it easy to reuse across websites, mobile apps, and other platforms. The embedded Studio approach means content editors get a polished editing experience without needing a separate application.
The key takeaways from this tutorial:
- Sanity schemas are code-first and fully typed
- GROQ queries give you precise control over data fetching
- Portable Text provides rich content with custom rendering
- Live preview and on-demand revalidation create a seamless editing workflow
- The image pipeline handles optimization automatically
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Building a Content-Driven Website with Payload CMS 3 and Next.js
Learn how to build a full-featured content-driven website using Payload CMS 3, which runs natively inside Next.js App Router. This tutorial covers collections, rich text editing, media uploads, authentication, and deploying to production.

Build a Full-Stack App with Strapi 5 and Next.js 15 App Router
Learn how to build a full-stack application with Strapi 5 as your headless CMS and Next.js 15 App Router for the frontend. This tutorial covers content modeling, REST APIs, server-side rendering, and production deployment.

Medusa.js 2.0 — Build a Headless E-commerce Store with Next.js (2026)
Learn how to build a complete headless e-commerce store using Medusa.js 2.0 and Next.js. From product catalog to checkout, this tutorial covers the full stack with TypeScript.