Sanity - Kompletny Przewodnik po Structured Content Platform
Czym jest Sanity?
Sanity to headless CMS nowej generacji, który traktuje content jako dane strukturalne zamiast bloków tekstu. W przeciwieństwie do tradycyjnych CMS-ów jak WordPress czy nawet innych headless CMS-ów jak Contentful czy Strapi, Sanity oferuje pełną elastyczność w definiowaniu schematów contentu, potężny język zapytań GROQ i całkowicie customizable Studio do zarządzania treścią.
Sanity został założony w Oslo (Norwegia) w 2017 roku i szybko zdobył popularność wśród developerów, którzy potrzebowali CMS-a, który nie ogranicza ich możliwości. Platforma jest używana przez firmy takie jak Nike, Burger King, Figma, Cloudflare, Spotify i Vercel.
Kluczową cechą Sanity jest "Content Lake" - scentralizowany magazyn treści, z którego możesz pobierać dane przez API, a także "Sanity Studio" - open-source'owa aplikacja React do zarządzania contentem, którą możesz całkowicie dostosować do swoich potrzeb.
Dlaczego Sanity?
Kluczowe zalety
- Structured Content - Content jako dane, nie bloki tekstu
- GROQ - Potężny język zapytań stworzony dla contentu
- Real-time collaboration - Edycja w czasie rzeczywistym
- Customizable Studio - Pełna kontrola nad edytorem
- Portable Text - Rich text jako JSON
- Image pipeline - Transformacje obrazów on-the-fly
- TypeScript-first - Pełna typizacja schematów
- Generous free tier - 100k API requests/month
Sanity vs Contentful vs Strapi
| Cecha | Sanity | Contentful | Strapi |
|---|---|---|---|
| Hosting | Cloud | Cloud | Self-hosted |
| Query language | GROQ | GraphQL | REST/GraphQL |
| Studio customization | ✅ Pełna | ❌ Ograniczona | ✅ Plugin system |
| Real-time collab | ✅ Native | ❌ Brak | ❌ Brak |
| Portable Text | ✅ Native | ❌ Brak | ❌ Brak |
| Image transforms | ✅ Built-in | ✅ Built-in | ❌ Plugin |
| Free tier | 100k req/mo | 1M req/mo (limited) | Free (self-host) |
| Open source | Studio only | ❌ | ✅ Pełne |
Rozpoczęcie pracy z Sanity
Tworzenie projektu
# Instalacja CLI
npm install -g sanity
# Tworzenie nowego projektu
npm create sanity@latest
# Odpowiedz na pytania:
# ? Project name: my-blog
# ? Default dataset configuration: production
# ? Project output path: my-blog
# ? Select a template: Blog (schema)
# Uruchomienie Studio
cd my-blog
npm run dev
# Studio dostępne na http://localhost:3333Struktura projektu
my-blog/
├── schemas/
│ ├── index.ts # Export wszystkich schematów
│ ├── post.ts # Schema posta
│ ├── author.ts # Schema autora
│ └── category.ts # Schema kategorii
├── sanity.config.ts # Konfiguracja Studio
├── sanity.cli.ts # Konfiguracja CLI
├── package.json
└── tsconfig.jsonSchema Definition
Podstawowy schema
// schemas/post.ts
import { defineType, defineField } from 'sanity'
export default defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required().min(10).max(100)
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96
},
validation: (Rule) => Rule.required()
}),
defineField({
name: 'publishedAt',
title: 'Published at',
type: 'datetime',
initialValue: () => new Date().toISOString()
}),
defineField({
name: 'excerpt',
title: 'Excerpt',
type: 'text',
rows: 3,
validation: (Rule) => Rule.max(200)
}),
defineField({
name: 'mainImage',
title: 'Main image',
type: 'image',
options: {
hotspot: true // Pozwala na kadrowanie
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text',
description: 'Important for SEO and accessibility'
}
]
}),
defineField({
name: 'body',
title: 'Body',
type: 'blockContent' // Portable Text
}),
defineField({
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }]
}),
defineField({
name: 'categories',
title: 'Categories',
type: 'array',
of: [{ type: 'reference', to: { type: 'category' } }]
})
],
preview: {
select: {
title: 'title',
author: 'author.name',
media: 'mainImage'
},
prepare(selection) {
const { author } = selection
return { ...selection, subtitle: author && `by ${author}` }
}
}
})Author schema
// schemas/author.ts
import { defineType, defineField } from 'sanity'
export default 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' }
}),
defineField({
name: 'image',
title: 'Image',
type: 'image',
options: { hotspot: true }
}),
defineField({
name: 'bio',
title: 'Bio',
type: 'array',
of: [{ type: 'block' }]
}),
defineField({
name: 'social',
title: 'Social Links',
type: 'object',
fields: [
{ name: 'twitter', type: 'url', title: 'Twitter' },
{ name: 'github', type: 'url', title: 'GitHub' },
{ name: 'linkedin', type: 'url', title: 'LinkedIn' }
]
})
],
preview: {
select: {
title: 'name',
media: 'image'
}
}
})Portable Text (Rich Content)
// schemas/blockContent.ts
import { defineType, defineArrayMember } from 'sanity'
export default defineType({
title: 'Block Content',
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'H4', value: 'h4' },
{ title: 'Quote', value: 'blockquote' }
],
lists: [
{ title: 'Bullet', value: 'bullet' },
{ title: 'Numbered', value: 'number' }
],
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
{ title: 'Underline', value: 'underline' },
{ title: 'Strike', value: 'strike-through' }
],
annotations: [
{
title: 'URL',
name: 'link',
type: 'object',
fields: [
{
title: 'URL',
name: 'href',
type: 'url',
validation: (Rule) =>
Rule.uri({
scheme: ['http', 'https', 'mailto', 'tel']
})
},
{
title: 'Open in new tab',
name: 'blank',
type: 'boolean'
}
]
},
{
title: 'Internal Link',
name: 'internalLink',
type: 'object',
fields: [
{
name: 'reference',
type: 'reference',
to: [{ type: 'post' }, { type: 'author' }]
}
]
}
]
}
}),
// Embedded content
defineArrayMember({
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text'
},
{
name: 'caption',
type: 'string',
title: 'Caption'
}
]
}),
defineArrayMember({
type: 'code',
title: 'Code Block',
options: {
language: 'typescript',
languageAlternatives: [
{ title: 'TypeScript', value: 'typescript' },
{ title: 'JavaScript', value: 'javascript' },
{ title: 'HTML', value: 'html' },
{ title: 'CSS', value: 'css' },
{ title: 'JSON', value: 'json' },
{ title: 'Bash', value: 'bash' }
],
withFilename: true
}
}),
defineArrayMember({
name: 'youtube',
type: 'object',
title: 'YouTube Video',
fields: [
{
name: 'url',
type: 'url',
title: 'YouTube URL'
}
],
preview: {
select: { url: 'url' },
prepare({ url }) {
return { title: 'YouTube Video', subtitle: url }
}
}
})
]
})Rejestrowanie schematów
// schemas/index.ts
import post from './post'
import author from './author'
import category from './category'
import blockContent from './blockContent'
export const schemaTypes = [post, author, category, blockContent]// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { codeInput } from '@sanity/code-input'
import { schemaTypes } from './schemas'
export default defineConfig({
name: 'default',
title: 'My Blog',
projectId: 'your-project-id',
dataset: 'production',
plugins: [
structureTool(),
visionTool(), // GROQ playground
codeInput() // Code blocks
],
schema: {
types: schemaTypes
}
})GROQ - Język zapytań
GROQ (Graph-Relational Object Queries) to język zapytań stworzony przez Sanity, zoptymalizowany dla contentu.
Podstawowe zapytania
// Wszystkie posty
*[_type == "post"]
// Post po slug
*[_type == "post" && slug.current == "my-post"][0]
// Posty z określonymi polami
*[_type == "post"] {
title,
slug,
excerpt,
publishedAt
}
// Posty z referencjami (expand)
*[_type == "post"] {
title,
slug,
"author": author->name,
"authorImage": author->image,
"categories": categories[]->title
}
// Sortowanie i limitowanie
*[_type == "post"] | order(publishedAt desc) [0...10]
// Filtrowanie
*[_type == "post" && publishedAt < now()] | order(publishedAt desc)
// Wyszukiwanie
*[_type == "post" && title match "Next.js*"]Zaawansowane zapytania
// Pełny post z powiązaniami
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
excerpt,
body,
"mainImage": mainImage {
asset->{
_id,
url,
metadata {
dimensions,
lqip
}
},
alt
},
"author": author-> {
name,
slug,
image,
bio
},
"categories": categories[]-> {
title,
slug
},
"relatedPosts": *[_type == "post" &&
_id != ^._id &&
count(categories[@._ref in ^.^.categories[]._ref]) > 0
] | order(publishedAt desc) [0...3] {
title,
slug,
mainImage
}
}
// Statystyki
{
"totalPosts": count(*[_type == "post"]),
"publishedPosts": count(*[_type == "post" && publishedAt < now()]),
"draftPosts": count(*[_type == "post" && !defined(publishedAt)]),
"authors": count(*[_type == "author"]),
"postsByCategory": *[_type == "category"] {
title,
"count": count(*[_type == "post" && references(^._id)])
}
}
// Pagination
{
"items": *[_type == "post"] | order(publishedAt desc) [$start...$end],
"total": count(*[_type == "post"])
}GROQ w kodzie
// lib/sanity.ts
import { createClient } from '@sanity/client'
import imageUrlBuilder from '@sanity/image-url'
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, // false dla preview
token: process.env.SANITY_API_TOKEN // dla mutacji
})
const builder = imageUrlBuilder(client)
export function urlFor(source: any) {
return builder.image(source)
}
// Typowane zapytania
import { groq } from 'next-sanity'
export const postsQuery = groq`
*[_type == "post" && publishedAt < now()] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
publishedAt,
"author": author->name,
"imageUrl": mainImage.asset->url
}
`
export const postBySlugQuery = groq`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
body,
publishedAt,
"author": author->{name, image, bio},
"categories": categories[]->title
}
`Next.js Integration
Setup
npm install @sanity/client @sanity/image-url next-sanityKonfiguracja
// lib/sanity.ts
import { createClient, type QueryParams } 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: process.env.NODE_ENV === 'production'
})
export async function sanityFetch<T>({
query,
params = {},
tags = []
}: {
query: string
params?: QueryParams
tags?: string[]
}): Promise<T> {
return client.fetch<T>(query, params, {
next: {
revalidate: process.env.NODE_ENV === 'development' ? 0 : 3600,
tags
}
})
}App Router - Lista postów
// app/blog/page.tsx
import { sanityFetch } from '@/lib/sanity'
import { postsQuery } from '@/lib/queries'
import Link from 'next/link'
import Image from 'next/image'
import { urlFor } from '@/lib/sanity'
interface Post {
_id: string
title: string
slug: { current: string }
excerpt: string
publishedAt: string
author: string
imageUrl: string
}
export default async function BlogPage() {
const posts = await sanityFetch<Post[]>({
query: postsQuery,
tags: ['post']
})
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<article key={post._id} className="bg-white rounded-lg shadow-md overflow-hidden">
{post.imageUrl && (
<Image
src={urlFor(post.imageUrl).width(400).height(250).url()}
alt={post.title}
width={400}
height={250}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<time className="text-sm text-gray-500">
{new Date(post.publishedAt).toLocaleDateString('pl-PL')}
</time>
<h2 className="text-xl font-semibold mt-2">
<Link href={`/blog/${post.slug.current}`} className="hover:text-blue-600">
{post.title}
</Link>
</h2>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
<p className="text-sm text-gray-500 mt-2">by {post.author}</p>
</div>
</article>
))}
</div>
</div>
)
}App Router - Pojedynczy post
// app/blog/[slug]/page.tsx
import { sanityFetch } from '@/lib/sanity'
import { postBySlugQuery } from '@/lib/queries'
import { PortableText } from '@portabletext/react'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import { urlFor } from '@/lib/sanity'
interface Post {
_id: string
title: string
body: any[]
publishedAt: string
author: {
name: string
image: any
bio: any[]
}
categories: string[]
}
interface Props {
params: { slug: string }
}
export async function generateMetadata({ params }: Props) {
const post = await sanityFetch<Post | null>({
query: postBySlugQuery,
params: { slug: params.slug }
})
if (!post) return { title: 'Post not found' }
return {
title: post.title,
description: post.body?.[0]?.children?.[0]?.text?.slice(0, 160)
}
}
export default async function PostPage({ params }: Props) {
const post = await sanityFetch<Post | null>({
query: postBySlugQuery,
params: { slug: params.slug },
tags: ['post']
})
if (!post) notFound()
return (
<article className="container mx-auto px-4 py-8 max-w-3xl">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4">
{post.author.image && (
<Image
src={urlFor(post.author.image).width(48).height(48).url()}
alt={post.author.name}
width={48}
height={48}
className="rounded-full"
/>
)}
<div>
<p className="font-medium">{post.author.name}</p>
<time className="text-gray-500">
{new Date(post.publishedAt).toLocaleDateString('pl-PL')}
</time>
</div>
</div>
<div className="flex gap-2 mt-4">
{post.categories?.map((cat) => (
<span key={cat} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
{cat}
</span>
))}
</div>
</header>
<div className="prose prose-lg max-w-none">
<PortableText
value={post.body}
components={portableTextComponents}
/>
</div>
</article>
)
}Portable Text Components
// components/PortableTextComponents.tsx
import Image from 'next/image'
import Link from 'next/link'
import { urlFor } from '@/lib/sanity'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
export const portableTextComponents = {
types: {
image: ({ value }: any) => (
<figure className="my-8">
<Image
src={urlFor(value).width(800).url()}
alt={value.alt || ''}
width={800}
height={500}
className="rounded-lg"
/>
{value.caption && (
<figcaption className="text-center text-gray-500 mt-2">
{value.caption}
</figcaption>
)}
</figure>
),
code: ({ value }: any) => (
<div className="my-6">
{value.filename && (
<div className="bg-gray-800 text-gray-200 px-4 py-2 rounded-t-lg text-sm">
{value.filename}
</div>
)}
<SyntaxHighlighter
language={value.language || 'typescript'}
style={vscDarkPlus}
className={`!mt-0 ${value.filename ? '!rounded-t-none' : ''}`}
>
{value.code}
</SyntaxHighlighter>
</div>
),
youtube: ({ value }: any) => {
const videoId = value.url.match(
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&]+)/
)?.[1]
return (
<div className="aspect-video my-8">
<iframe
src={`https://www.youtube.com/embed/${videoId}`}
className="w-full h-full rounded-lg"
allow="accelerometer; autoplay; clipboard-write; encrypted-media"
allowFullScreen
/>
</div>
)
}
},
marks: {
link: ({ children, value }: any) => (
<a
href={value.href}
target={value.blank ? '_blank' : undefined}
rel={value.blank ? 'noopener noreferrer' : undefined}
className="text-blue-600 hover:underline"
>
{children}
</a>
),
internalLink: ({ children, value }: any) => (
<Link href={`/${value.reference._type}/${value.reference.slug.current}`}>
{children}
</Link>
),
code: ({ children }: any) => (
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
)
},
block: {
h2: ({ children }: any) => (
<h2 className="text-3xl font-bold mt-10 mb-4">{children}</h2>
),
h3: ({ children }: any) => (
<h3 className="text-2xl font-bold mt-8 mb-3">{children}</h3>
),
blockquote: ({ children }: any) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic my-6">
{children}
</blockquote>
)
}
}Image Pipeline
Sanity ma potężny pipeline do transformacji obrazów.
Podstawowe transformacje
import { urlFor } from '@/lib/sanity'
// Podstawowy URL
urlFor(image).url()
// Rozmiar
urlFor(image).width(800).height(600).url()
urlFor(image).size(800, 600).url()
// Fit modes
urlFor(image).width(400).height(300).fit('clip').url() // Obcina
urlFor(image).width(400).height(300).fit('crop').url() // Kadruje
urlFor(image).width(400).height(300).fit('fill').url() // Wypełnia
urlFor(image).width(400).height(300).fit('max').url() // Max dimensions
urlFor(image).width(400).height(300).fit('min').url() // Min dimensions
urlFor(image).width(400).height(300).fit('scale').url() // Skaluje
// Format
urlFor(image).format('webp').url()
urlFor(image).format('auto').url() // Auto-detect best format
// Quality
urlFor(image).quality(80).url()
// Auto format i quality
urlFor(image).auto('format').quality(80).url()
// Blur
urlFor(image).blur(50).url()
// LQIP (Low Quality Image Placeholder)
urlFor(image).width(20).blur(10).quality(20).url()
// Chaining
urlFor(image)
.width(800)
.height(600)
.fit('crop')
.auto('format')
.quality(80)
.url()Hotspot & Crop
// Jeśli obraz ma zdefiniowany hotspot w Studio:
urlFor(image)
.width(400)
.height(400)
.fit('crop')
.crop('focalpoint') // Kadruje wokół hotspot
.url()
// Lub entropy (AI-based)
urlFor(image)
.width(400)
.height(400)
.fit('crop')
.crop('entropy') // Kadruje na najbardziej "interesujący" fragment
.url()Responsive images
// components/SanityImage.tsx
import Image from 'next/image'
import { urlFor } from '@/lib/sanity'
interface SanityImageProps {
image: any
alt: string
sizes?: string
priority?: boolean
}
export function SanityImage({ image, alt, sizes, priority }: SanityImageProps) {
const imageUrl = urlFor(image).auto('format').quality(80).url()
return (
<Image
src={imageUrl}
alt={alt}
width={image.asset.metadata.dimensions.width}
height={image.asset.metadata.dimensions.height}
sizes={sizes || '100vw'}
priority={priority}
placeholder="blur"
blurDataURL={image.asset.metadata.lqip} // Base64 LQIP
/>
)
}Studio Customization
Custom desk structure
// sanity.config.ts
import { structureTool } from 'sanity/structure'
export default defineConfig({
// ...
plugins: [
structureTool({
structure: (S) =>
S.list()
.title('Content')
.items([
// Posts with drafts/published split
S.listItem()
.title('Posts')
.child(
S.list()
.title('Posts')
.items([
S.listItem()
.title('Published')
.child(
S.documentList()
.title('Published Posts')
.filter('_type == "post" && publishedAt < now()')
),
S.listItem()
.title('Drafts')
.child(
S.documentList()
.title('Draft Posts')
.filter('_type == "post" && !defined(publishedAt)')
),
S.listItem()
.title('All Posts')
.child(S.documentTypeList('post'))
])
),
// Authors
S.documentTypeListItem('author').title('Authors'),
// Categories
S.documentTypeListItem('category').title('Categories'),
// Divider
S.divider(),
// Settings (singleton)
S.listItem()
.title('Settings')
.child(S.document().schemaType('settings').documentId('settings'))
])
})
]
})Custom input components
// components/studio/CharacterCount.tsx
import { StringInputProps, useFormValue } from 'sanity'
export function CharacterCountInput(props: StringInputProps) {
const { value = '', elementProps, schemaType } = props
const maxLength = schemaType.validation?.[0]?._rules?.find(
(r: any) => r.flag === 'max'
)?.constraint
return (
<div>
{props.renderDefault(props)}
<div className="mt-2 text-sm text-gray-500">
{value.length} {maxLength && `/ ${maxLength}`} characters
</div>
</div>
)
}
// Użycie w schema
defineField({
name: 'title',
type: 'string',
validation: (Rule) => Rule.max(100),
components: {
input: CharacterCountInput
}
})Document actions
// actions/publishAction.ts
import { DocumentActionComponent } from 'sanity'
export const PublishWithNotification: DocumentActionComponent = (props) => {
const { publish } = props
return {
label: 'Publish & Notify',
onHandle: async () => {
// Publikuj dokument
publish.execute()
// Wyślij notyfikację
await fetch('/api/notify', {
method: 'POST',
body: JSON.stringify({ documentId: props.id })
})
}
}
}
// sanity.config.ts
import { PublishWithNotification } from './actions/publishAction'
export default defineConfig({
// ...
document: {
actions: (prev, context) => {
if (context.schemaType === 'post') {
return [...prev, PublishWithNotification]
}
return prev
}
}
})Real-time Preview
Konfiguracja preview
// sanity.config.ts
export default defineConfig({
// ...
plugins: [
structureTool({
defaultDocumentNode: (S, { schemaType }) => {
if (schemaType === 'post') {
return S.document().views([
S.view.form(),
S.view.component(IframePreview).title('Preview')
])
}
}
})
]
})
// components/studio/IframePreview.tsx
import { useFormValue } from 'sanity'
export function IframePreview() {
const slug = useFormValue(['slug', 'current'])
const previewUrl = slug
? `${process.env.SANITY_STUDIO_PREVIEW_URL}/api/preview?slug=${slug}`
: null
if (!previewUrl) {
return <div>Enter a slug to preview</div>
}
return (
<iframe
src={previewUrl}
style={{ width: '100%', height: '100%', border: 0 }}
/>
)
}Next.js Preview API
// 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')
if (!slug) {
return new Response('Missing slug', { status: 400 })
}
draftMode().enable()
redirect(`/blog/${slug}`)
}
// app/api/disable-draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET() {
draftMode().disable()
redirect('/')
}Draft-aware fetching
// lib/sanity.ts
import { draftMode } from 'next/headers'
export async function sanityFetch<T>({
query,
params = {},
tags = []
}: {
query: string
params?: QueryParams
tags?: string[]
}): Promise<T> {
const isDraft = draftMode().isEnabled
return client.fetch<T>(
query,
params,
{
token: isDraft ? process.env.SANITY_API_READ_TOKEN : undefined,
perspective: isDraft ? 'previewDrafts' : 'published',
next: {
revalidate: isDraft ? 0 : 3600,
tags
}
}
)
}Webhooks i Revalidation
On-demand revalidation
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
import { parseBody } from 'next-sanity/webhook'
export async function POST(req: NextRequest) {
const { body, isValidSignature } = await parseBody<{
_type: string
slug?: { current: string }
}>(req, process.env.SANITY_REVALIDATE_SECRET)
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 })
}
if (!body?._type) {
return new Response('Bad Request', { status: 400 })
}
// Revalidate by tag
revalidateTag(body._type)
// Revalidate specific path
if (body._type === 'post' && body.slug?.current) {
revalidateTag(`post-${body.slug.current}`)
}
return Response.json({ revalidated: true })
}Konfiguracja webhook w Sanity
Sanity Dashboard → Project → API → Webhooks
Name: Next.js Revalidation
URL: https://your-site.com/api/revalidate
Trigger on: Create, Update, Delete
Filter: _type == "post" || _type == "author"
Secret: your-webhook-secretCennik
Plany
Free:
├── 100k API requests/mo
├── 10GB bandwidth/mo
├── 5GB assets
├── 3 editors
├── 2 datasets
└── Community support
Growth ($15/mo per project):
├── 1M API requests/mo
├── 100GB bandwidth/mo
├── 50GB assets
├── 20 editors
├── 10 datasets
├── Custom roles
└── Priority support
Enterprise (custom):
├── Unlimited requests
├── Unlimited bandwidth
├── Unlimited assets
├── Unlimited editors
├── SSO/SAML
├── SLA
└── Dedicated supportOverages
API requests: $0.05 per 1000 after limit
Bandwidth: $0.10 per GB after limit
Assets: $0.50 per GB/month after limitFAQ - Często zadawane pytania
Sanity vs Contentful - co wybrać?
Wybierz Sanity gdy:
- Potrzebujesz pełnej customizacji Studio
- Cenisz real-time collaboration
- Lubisz GROQ jako język zapytań
- Potrzebujesz Portable Text
Wybierz Contentful gdy:
- Potrzebujesz prostszego UI dla edytorów
- Większy ekosystem gotowych integracji
- Preferujesz GraphQL
Czy Sanity jest darmowy?
Tak, free tier oferuje 100k API requests/month, co wystarcza dla większości blogów i małych projektów. Growth plan ($15/mo) daje 1M requests.
Jak zmigrować z WordPressa?
- Eksportuj content z WP jako JSON
- Napisz skrypt transformujący na Sanity schema
- Użyj Sanity CLI do importu
- Mapuj obrazy i relacje
Czy mogę self-hostować Sanity?
Sanity Studio (edytor) możesz hostować samemu - to aplikacja React. Jednak Content Lake (baza) jest zawsze hostowany przez Sanity.
Jak działa wersjonowanie?
Sanity automatycznie tworzy drafty. Każda zmiana to nowa wersja draftu. Po publikacji draft staje się opublikowanym dokumentem. Historia zmian jest dostępna w Studio.
Podsumowanie
Sanity to potężna platforma content nowej generacji oferująca:
- Structured Content - Content jako dane, nie tekst
- GROQ - Elastyczny język zapytań
- Portable Text - Rich text jako JSON
- Customizable Studio - Pełna kontrola nad edytorem
- Image Pipeline - Transformacje on-the-fly
- Real-time collaboration - Edycja w czasie rzeczywistym
Sanity jest idealny dla zespołów, które potrzebują elastyczności i kontroli nad swoim CMS-em.
Sanity - a complete guide to the structured content platform
What is Sanity?
Sanity is a next-generation headless CMS that treats content as structured data rather than text blocks. Unlike traditional CMSs like WordPress or even other headless CMSs like Contentful or Strapi, Sanity offers complete flexibility in defining content schemas, a powerful GROQ query language, and a fully customizable Studio for content management.
Sanity was founded in Oslo (Norway) in 2017 and quickly gained popularity among developers who needed a CMS that does not limit their capabilities. The platform is used by companies like Nike, Burger King, Figma, Cloudflare, Spotify, and Vercel.
A key feature of Sanity is the "Content Lake" - a centralized content store from which you can fetch data via API, as well as "Sanity Studio" - an open-source React application for managing content that you can fully customize to your needs.
Why Sanity?
Key advantages
- Structured Content - Content as data, not text blocks
- GROQ - Powerful query language built for content
- Real-time collaboration - Editing in real time
- Customizable Studio - Full control over the editor
- Portable Text - Rich text as JSON
- Image pipeline - On-the-fly image transformations
- TypeScript-first - Full schema typing
- Generous free tier - 100k API requests/month
Sanity vs Contentful vs Strapi
| Feature | Sanity | Contentful | Strapi |
|---|---|---|---|
| Hosting | Cloud | Cloud | Self-hosted |
| Query language | GROQ | GraphQL | REST/GraphQL |
| Studio customization | ✅ Full | ❌ Limited | ✅ Plugin system |
| Real-time collab | ✅ Native | ❌ None | ❌ None |
| Portable Text | ✅ Native | ❌ None | ❌ None |
| Image transforms | ✅ Built-in | ✅ Built-in | ❌ Plugin |
| Free tier | 100k req/mo | 1M req/mo (limited) | Free (self-host) |
| Open source | Studio only | ❌ | ✅ Full |
Getting started with Sanity
Creating a project
# Install CLI
npm install -g sanity
# Create a new project
npm create sanity@latest
# Answer the prompts:
# ? Project name: my-blog
# ? Default dataset configuration: production
# ? Project output path: my-blog
# ? Select a template: Blog (schema)
# Start Studio
cd my-blog
npm run dev
# Studio available at http://localhost:3333Project structure
my-blog/
├── schemas/
│ ├── index.ts # Export of all schemas
│ ├── post.ts # Post schema
│ ├── author.ts # Author schema
│ └── category.ts # Category schema
├── sanity.config.ts # Studio configuration
├── sanity.cli.ts # CLI configuration
├── package.json
└── tsconfig.jsonSchema definition
Basic schema
// schemas/post.ts
import { defineType, defineField } from 'sanity'
export default defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule) => Rule.required().min(10).max(100)
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96
},
validation: (Rule) => Rule.required()
}),
defineField({
name: 'publishedAt',
title: 'Published at',
type: 'datetime',
initialValue: () => new Date().toISOString()
}),
defineField({
name: 'excerpt',
title: 'Excerpt',
type: 'text',
rows: 3,
validation: (Rule) => Rule.max(200)
}),
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: 'body',
title: 'Body',
type: 'blockContent'
}),
defineField({
name: 'author',
title: 'Author',
type: 'reference',
to: [{ type: 'author' }]
}),
defineField({
name: 'categories',
title: 'Categories',
type: 'array',
of: [{ type: 'reference', to: { type: 'category' } }]
})
],
preview: {
select: {
title: 'title',
author: 'author.name',
media: 'mainImage'
},
prepare(selection) {
const { author } = selection
return { ...selection, subtitle: author && `by ${author}` }
}
}
})Author schema
// schemas/author.ts
import { defineType, defineField } from 'sanity'
export default 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' }
}),
defineField({
name: 'image',
title: 'Image',
type: 'image',
options: { hotspot: true }
}),
defineField({
name: 'bio',
title: 'Bio',
type: 'array',
of: [{ type: 'block' }]
}),
defineField({
name: 'social',
title: 'Social Links',
type: 'object',
fields: [
{ name: 'twitter', type: 'url', title: 'Twitter' },
{ name: 'github', type: 'url', title: 'GitHub' },
{ name: 'linkedin', type: 'url', title: 'LinkedIn' }
]
})
],
preview: {
select: {
title: 'name',
media: 'image'
}
}
})Portable Text (rich content)
// schemas/blockContent.ts
import { defineType, defineArrayMember } from 'sanity'
export default defineType({
title: 'Block Content',
name: 'blockContent',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
{ title: 'H4', value: 'h4' },
{ title: 'Quote', value: 'blockquote' }
],
lists: [
{ title: 'Bullet', value: 'bullet' },
{ title: 'Numbered', value: 'number' }
],
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
{ title: 'Underline', value: 'underline' },
{ title: 'Strike', value: 'strike-through' }
],
annotations: [
{
title: 'URL',
name: 'link',
type: 'object',
fields: [
{
title: 'URL',
name: 'href',
type: 'url',
validation: (Rule) =>
Rule.uri({
scheme: ['http', 'https', 'mailto', 'tel']
})
},
{
title: 'Open in new tab',
name: 'blank',
type: 'boolean'
}
]
},
{
title: 'Internal Link',
name: 'internalLink',
type: 'object',
fields: [
{
name: 'reference',
type: 'reference',
to: [{ type: 'post' }, { type: 'author' }]
}
]
}
]
}
}),
defineArrayMember({
type: 'image',
options: { hotspot: true },
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text'
},
{
name: 'caption',
type: 'string',
title: 'Caption'
}
]
}),
defineArrayMember({
type: 'code',
title: 'Code Block',
options: {
language: 'typescript',
languageAlternatives: [
{ title: 'TypeScript', value: 'typescript' },
{ title: 'JavaScript', value: 'javascript' },
{ title: 'HTML', value: 'html' },
{ title: 'CSS', value: 'css' },
{ title: 'JSON', value: 'json' },
{ title: 'Bash', value: 'bash' }
],
withFilename: true
}
}),
defineArrayMember({
name: 'youtube',
type: 'object',
title: 'YouTube Video',
fields: [
{
name: 'url',
type: 'url',
title: 'YouTube URL'
}
],
preview: {
select: { url: 'url' },
prepare({ url }) {
return { title: 'YouTube Video', subtitle: url }
}
}
})
]
})Registering schemas
// schemas/index.ts
import post from './post'
import author from './author'
import category from './category'
import blockContent from './blockContent'
export const schemaTypes = [post, author, category, blockContent]// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { codeInput } from '@sanity/code-input'
import { schemaTypes } from './schemas'
export default defineConfig({
name: 'default',
title: 'My Blog',
projectId: 'your-project-id',
dataset: 'production',
plugins: [
structureTool(),
visionTool(),
codeInput()
],
schema: {
types: schemaTypes
}
})GROQ - query language
GROQ (Graph-Relational Object Queries) is a query language created by Sanity, optimized for content.
Basic queries
// All posts
*[_type == "post"]
// Post by slug
*[_type == "post" && slug.current == "my-post"][0]
// Posts with specific fields
*[_type == "post"] {
title,
slug,
excerpt,
publishedAt
}
// Posts with references (expand)
*[_type == "post"] {
title,
slug,
"author": author->name,
"authorImage": author->image,
"categories": categories[]->title
}
// Sorting and limiting
*[_type == "post"] | order(publishedAt desc) [0...10]
// Filtering
*[_type == "post" && publishedAt < now()] | order(publishedAt desc)
// Searching
*[_type == "post" && title match "Next.js*"]Advanced queries
// Full post with relations
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
excerpt,
body,
"mainImage": mainImage {
asset->{
_id,
url,
metadata {
dimensions,
lqip
}
},
alt
},
"author": author-> {
name,
slug,
image,
bio
},
"categories": categories[]-> {
title,
slug
},
"relatedPosts": *[_type == "post" &&
_id != ^._id &&
count(categories[@._ref in ^.^.categories[]._ref]) > 0
] | order(publishedAt desc) [0...3] {
title,
slug,
mainImage
}
}
// Statistics
{
"totalPosts": count(*[_type == "post"]),
"publishedPosts": count(*[_type == "post" && publishedAt < now()]),
"draftPosts": count(*[_type == "post" && !defined(publishedAt)]),
"authors": count(*[_type == "author"]),
"postsByCategory": *[_type == "category"] {
title,
"count": count(*[_type == "post" && references(^._id)])
}
}
// Pagination
{
"items": *[_type == "post"] | order(publishedAt desc) [$start...$end],
"total": count(*[_type == "post"])
}GROQ in code
// lib/sanity.ts
import { createClient } from '@sanity/client'
import imageUrlBuilder from '@sanity/image-url'
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,
token: process.env.SANITY_API_TOKEN
})
const builder = imageUrlBuilder(client)
export function urlFor(source: any) {
return builder.image(source)
}
import { groq } from 'next-sanity'
export const postsQuery = groq`
*[_type == "post" && publishedAt < now()] | order(publishedAt desc) {
_id,
title,
slug,
excerpt,
publishedAt,
"author": author->name,
"imageUrl": mainImage.asset->url
}
`
export const postBySlugQuery = groq`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
body,
publishedAt,
"author": author->{name, image, bio},
"categories": categories[]->title
}
`Next.js integration
Setup
npm install @sanity/client @sanity/image-url next-sanityConfiguration
// lib/sanity.ts
import { createClient, type QueryParams } 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: process.env.NODE_ENV === 'production'
})
export async function sanityFetch<T>({
query,
params = {},
tags = []
}: {
query: string
params?: QueryParams
tags?: string[]
}): Promise<T> {
return client.fetch<T>(query, params, {
next: {
revalidate: process.env.NODE_ENV === 'development' ? 0 : 3600,
tags
}
})
}App Router - posts list
// app/blog/page.tsx
import { sanityFetch } from '@/lib/sanity'
import { postsQuery } from '@/lib/queries'
import Link from 'next/link'
import Image from 'next/image'
import { urlFor } from '@/lib/sanity'
interface Post {
_id: string
title: string
slug: { current: string }
excerpt: string
publishedAt: string
author: string
imageUrl: string
}
export default async function BlogPage() {
const posts = await sanityFetch<Post[]>({
query: postsQuery,
tags: ['post']
})
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<article key={post._id} className="bg-white rounded-lg shadow-md overflow-hidden">
{post.imageUrl && (
<Image
src={urlFor(post.imageUrl).width(400).height(250).url()}
alt={post.title}
width={400}
height={250}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<time className="text-sm text-gray-500">
{new Date(post.publishedAt).toLocaleDateString('pl-PL')}
</time>
<h2 className="text-xl font-semibold mt-2">
<Link href={`/blog/${post.slug.current}`} className="hover:text-blue-600">
{post.title}
</Link>
</h2>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
<p className="text-sm text-gray-500 mt-2">by {post.author}</p>
</div>
</article>
))}
</div>
</div>
)
}App Router - single post
// app/blog/[slug]/page.tsx
import { sanityFetch } from '@/lib/sanity'
import { postBySlugQuery } from '@/lib/queries'
import { PortableText } from '@portabletext/react'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import { urlFor } from '@/lib/sanity'
interface Post {
_id: string
title: string
body: any[]
publishedAt: string
author: {
name: string
image: any
bio: any[]
}
categories: string[]
}
interface Props {
params: { slug: string }
}
export async function generateMetadata({ params }: Props) {
const post = await sanityFetch<Post | null>({
query: postBySlugQuery,
params: { slug: params.slug }
})
if (!post) return { title: 'Post not found' }
return {
title: post.title,
description: post.body?.[0]?.children?.[0]?.text?.slice(0, 160)
}
}
export default async function PostPage({ params }: Props) {
const post = await sanityFetch<Post | null>({
query: postBySlugQuery,
params: { slug: params.slug },
tags: ['post']
})
if (!post) notFound()
return (
<article className="container mx-auto px-4 py-8 max-w-3xl">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4">
{post.author.image && (
<Image
src={urlFor(post.author.image).width(48).height(48).url()}
alt={post.author.name}
width={48}
height={48}
className="rounded-full"
/>
)}
<div>
<p className="font-medium">{post.author.name}</p>
<time className="text-gray-500">
{new Date(post.publishedAt).toLocaleDateString('pl-PL')}
</time>
</div>
</div>
<div className="flex gap-2 mt-4">
{post.categories?.map((cat) => (
<span key={cat} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
{cat}
</span>
))}
</div>
</header>
<div className="prose prose-lg max-w-none">
<PortableText
value={post.body}
components={portableTextComponents}
/>
</div>
</article>
)
}Portable Text components
// components/PortableTextComponents.tsx
import Image from 'next/image'
import Link from 'next/link'
import { urlFor } from '@/lib/sanity'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
export const portableTextComponents = {
types: {
image: ({ value }: any) => (
<figure className="my-8">
<Image
src={urlFor(value).width(800).url()}
alt={value.alt || ''}
width={800}
height={500}
className="rounded-lg"
/>
{value.caption && (
<figcaption className="text-center text-gray-500 mt-2">
{value.caption}
</figcaption>
)}
</figure>
),
code: ({ value }: any) => (
<div className="my-6">
{value.filename && (
<div className="bg-gray-800 text-gray-200 px-4 py-2 rounded-t-lg text-sm">
{value.filename}
</div>
)}
<SyntaxHighlighter
language={value.language || 'typescript'}
style={vscDarkPlus}
className={`!mt-0 ${value.filename ? '!rounded-t-none' : ''}`}
>
{value.code}
</SyntaxHighlighter>
</div>
),
youtube: ({ value }: any) => {
const videoId = value.url.match(
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&]+)/
)?.[1]
return (
<div className="aspect-video my-8">
<iframe
src={`https://www.youtube.com/embed/${videoId}`}
className="w-full h-full rounded-lg"
allow="accelerometer; autoplay; clipboard-write; encrypted-media"
allowFullScreen
/>
</div>
)
}
},
marks: {
link: ({ children, value }: any) => (
<a
href={value.href}
target={value.blank ? '_blank' : undefined}
rel={value.blank ? 'noopener noreferrer' : undefined}
className="text-blue-600 hover:underline"
>
{children}
</a>
),
internalLink: ({ children, value }: any) => (
<Link href={`/${value.reference._type}/${value.reference.slug.current}`}>
{children}
</Link>
),
code: ({ children }: any) => (
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
)
},
block: {
h2: ({ children }: any) => (
<h2 className="text-3xl font-bold mt-10 mb-4">{children}</h2>
),
h3: ({ children }: any) => (
<h3 className="text-2xl font-bold mt-8 mb-3">{children}</h3>
),
blockquote: ({ children }: any) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic my-6">
{children}
</blockquote>
)
}
}Image pipeline
Sanity has a powerful pipeline for image transformations.
Basic transformations
import { urlFor } from '@/lib/sanity'
// Basic URL
urlFor(image).url()
// Size
urlFor(image).width(800).height(600).url()
urlFor(image).size(800, 600).url()
// Fit modes
urlFor(image).width(400).height(300).fit('clip').url() // Clips
urlFor(image).width(400).height(300).fit('crop').url() // Crops
urlFor(image).width(400).height(300).fit('fill').url() // Fills
urlFor(image).width(400).height(300).fit('max').url() // Max dimensions
urlFor(image).width(400).height(300).fit('min').url() // Min dimensions
urlFor(image).width(400).height(300).fit('scale').url() // Scales
// Format
urlFor(image).format('webp').url()
urlFor(image).format('auto').url() // Auto-detect best format
// Quality
urlFor(image).quality(80).url()
// Auto format and quality
urlFor(image).auto('format').quality(80).url()
// Blur
urlFor(image).blur(50).url()
// LQIP (Low Quality Image Placeholder)
urlFor(image).width(20).blur(10).quality(20).url()
// Chaining
urlFor(image)
.width(800)
.height(600)
.fit('crop')
.auto('format')
.quality(80)
.url()Hotspot & crop
// If the image has a defined hotspot in Studio:
urlFor(image)
.width(400)
.height(400)
.fit('crop')
.crop('focalpoint') // Crops around the hotspot
.url()
// Or entropy (AI-based)
urlFor(image)
.width(400)
.height(400)
.fit('crop')
.crop('entropy') // Crops to the most "interesting" area
.url()Responsive images
// components/SanityImage.tsx
import Image from 'next/image'
import { urlFor } from '@/lib/sanity'
interface SanityImageProps {
image: any
alt: string
sizes?: string
priority?: boolean
}
export function SanityImage({ image, alt, sizes, priority }: SanityImageProps) {
const imageUrl = urlFor(image).auto('format').quality(80).url()
return (
<Image
src={imageUrl}
alt={alt}
width={image.asset.metadata.dimensions.width}
height={image.asset.metadata.dimensions.height}
sizes={sizes || '100vw'}
priority={priority}
placeholder="blur"
blurDataURL={image.asset.metadata.lqip}
/>
)
}Studio customization
Custom desk structure
// sanity.config.ts
import { structureTool } from 'sanity/structure'
export default defineConfig({
// ...
plugins: [
structureTool({
structure: (S) =>
S.list()
.title('Content')
.items([
S.listItem()
.title('Posts')
.child(
S.list()
.title('Posts')
.items([
S.listItem()
.title('Published')
.child(
S.documentList()
.title('Published Posts')
.filter('_type == "post" && publishedAt < now()')
),
S.listItem()
.title('Drafts')
.child(
S.documentList()
.title('Draft Posts')
.filter('_type == "post" && !defined(publishedAt)')
),
S.listItem()
.title('All Posts')
.child(S.documentTypeList('post'))
])
),
S.documentTypeListItem('author').title('Authors'),
S.documentTypeListItem('category').title('Categories'),
S.divider(),
S.listItem()
.title('Settings')
.child(S.document().schemaType('settings').documentId('settings'))
])
})
]
})Custom input components
// components/studio/CharacterCount.tsx
import { StringInputProps, useFormValue } from 'sanity'
export function CharacterCountInput(props: StringInputProps) {
const { value = '', elementProps, schemaType } = props
const maxLength = schemaType.validation?.[0]?._rules?.find(
(r: any) => r.flag === 'max'
)?.constraint
return (
<div>
{props.renderDefault(props)}
<div className="mt-2 text-sm text-gray-500">
{value.length} {maxLength && `/ ${maxLength}`} characters
</div>
</div>
)
}
defineField({
name: 'title',
type: 'string',
validation: (Rule) => Rule.max(100),
components: {
input: CharacterCountInput
}
})Document actions
// actions/publishAction.ts
import { DocumentActionComponent } from 'sanity'
export const PublishWithNotification: DocumentActionComponent = (props) => {
const { publish } = props
return {
label: 'Publish & Notify',
onHandle: async () => {
publish.execute()
await fetch('/api/notify', {
method: 'POST',
body: JSON.stringify({ documentId: props.id })
})
}
}
}
// sanity.config.ts
import { PublishWithNotification } from './actions/publishAction'
export default defineConfig({
// ...
document: {
actions: (prev, context) => {
if (context.schemaType === 'post') {
return [...prev, PublishWithNotification]
}
return prev
}
}
})Real-time preview
Preview configuration
// sanity.config.ts
export default defineConfig({
// ...
plugins: [
structureTool({
defaultDocumentNode: (S, { schemaType }) => {
if (schemaType === 'post') {
return S.document().views([
S.view.form(),
S.view.component(IframePreview).title('Preview')
])
}
}
})
]
})
// components/studio/IframePreview.tsx
import { useFormValue } from 'sanity'
export function IframePreview() {
const slug = useFormValue(['slug', 'current'])
const previewUrl = slug
? `${process.env.SANITY_STUDIO_PREVIEW_URL}/api/preview?slug=${slug}`
: null
if (!previewUrl) {
return <div>Enter a slug to preview</div>
}
return (
<iframe
src={previewUrl}
style={{ width: '100%', height: '100%', border: 0 }}
/>
)
}Next.js Preview API
// 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')
if (!slug) {
return new Response('Missing slug', { status: 400 })
}
draftMode().enable()
redirect(`/blog/${slug}`)
}
// app/api/disable-draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET() {
draftMode().disable()
redirect('/')
}Draft-aware fetching
// lib/sanity.ts
import { draftMode } from 'next/headers'
export async function sanityFetch<T>({
query,
params = {},
tags = []
}: {
query: string
params?: QueryParams
tags?: string[]
}): Promise<T> {
const isDraft = draftMode().isEnabled
return client.fetch<T>(
query,
params,
{
token: isDraft ? process.env.SANITY_API_READ_TOKEN : undefined,
perspective: isDraft ? 'previewDrafts' : 'published',
next: {
revalidate: isDraft ? 0 : 3600,
tags
}
}
)
}Webhooks and revalidation
On-demand revalidation
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
import { parseBody } from 'next-sanity/webhook'
export async function POST(req: NextRequest) {
const { body, isValidSignature } = await parseBody<{
_type: string
slug?: { current: string }
}>(req, process.env.SANITY_REVALIDATE_SECRET)
if (!isValidSignature) {
return new Response('Invalid signature', { status: 401 })
}
if (!body?._type) {
return new Response('Bad Request', { status: 400 })
}
revalidateTag(body._type)
if (body._type === 'post' && body.slug?.current) {
revalidateTag(`post-${body.slug.current}`)
}
return Response.json({ revalidated: true })
}Webhook configuration in Sanity
Sanity Dashboard → Project → API → Webhooks
Name: Next.js Revalidation
URL: https://your-site.com/api/revalidate
Trigger on: Create, Update, Delete
Filter: _type == "post" || _type == "author"
Secret: your-webhook-secretPricing
Plans
Free:
├── 100k API requests/mo
├── 10GB bandwidth/mo
├── 5GB assets
├── 3 editors
├── 2 datasets
└── Community support
Growth ($15/mo per project):
├── 1M API requests/mo
├── 100GB bandwidth/mo
├── 50GB assets
├── 20 editors
├── 10 datasets
├── Custom roles
└── Priority support
Enterprise (custom):
├── Unlimited requests
├── Unlimited bandwidth
├── Unlimited assets
├── Unlimited editors
├── SSO/SAML
├── SLA
└── Dedicated supportOverages
API requests: $0.05 per 1000 after limit
Bandwidth: $0.10 per GB after limit
Assets: $0.50 per GB/month after limitFAQ - frequently asked questions
Sanity vs Contentful - which one to choose?
Choose Sanity when:
- You need full Studio customization
- You value real-time collaboration
- You prefer GROQ as a query language
- You need Portable Text
Choose Contentful when:
- You need a simpler UI for editors
- You want a larger ecosystem of ready-made integrations
- You prefer GraphQL
Is Sanity free?
Yes, the free tier offers 100k API requests/month, which is enough for most blogs and small projects. The Growth plan ($15/mo) gives you 1M requests.
How to migrate from WordPress?
- Export content from WP as JSON
- Write a script to transform it into a Sanity schema
- Use the Sanity CLI to import the data
- Map images and relations
Can I self-host Sanity?
You can host Sanity Studio (the editor) yourself - it is a React application. However, Content Lake (the database) is always hosted by Sanity.
How does versioning work?
Sanity automatically creates drafts. Each change is a new draft version. After publishing, the draft becomes the published document. Change history is available in Studio.
Summary
Sanity is a powerful next-generation content platform offering:
- Structured Content - Content as data, not text
- GROQ - Flexible query language
- Portable Text - Rich text as JSON
- Customizable Studio - Full control over the editor
- Image Pipeline - On-the-fly transformations
- Real-time collaboration - Editing in real time
Sanity is ideal for teams that need flexibility and control over their CMS.