Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds
Torna alle collezioni
Guide34 min read

Sanity

Sanity is a headless CMS with structured content, real-time collaboration, GROQ query language and customizable Studio.

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

  1. Structured Content - Content jako dane, nie bloki tekstu
  2. GROQ - Potężny język zapytań stworzony dla contentu
  3. Real-time collaboration - Edycja w czasie rzeczywistym
  4. Customizable Studio - Pełna kontrola nad edytorem
  5. Portable Text - Rich text jako JSON
  6. Image pipeline - Transformacje obrazów on-the-fly
  7. TypeScript-first - Pełna typizacja schematów
  8. Generous free tier - 100k API requests/month

Sanity vs Contentful vs Strapi

CechaSanityContentfulStrapi
HostingCloudCloudSelf-hosted
Query languageGROQGraphQLREST/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 tier100k req/mo1M req/mo (limited)Free (self-host)
Open sourceStudio only✅ Pełne

Rozpoczęcie pracy z Sanity

Tworzenie projektu

Code
Bash
# 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:3333

Struktura projektu

Code
TEXT
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.json

Schema Definition

Podstawowy schema

TSschemas/post.ts
TypeScript
// 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

TSschemas/author.ts
TypeScript
// 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)

TSschemas/blockContent.ts
TypeScript
// 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

TSschemas/index.ts
TypeScript
// 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]
TSsanity.config.ts
TypeScript
// 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

Code
GROQ
// 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

Code
GROQ
// 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

TSlib/sanity.ts
TypeScript
// 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

Code
Bash
npm install @sanity/client @sanity/image-url next-sanity

Konfiguracja

TSlib/sanity.ts
TypeScript
// 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

TSapp/blog/page.tsx
TypeScript
// 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

TSapp/blog/[slug]/page.tsx
TypeScript
// 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

TScomponents/PortableTextComponents.tsx
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
// 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

TScomponents/SanityImage.tsx
TypeScript
// 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

TSsanity.config.ts
TypeScript
// 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

TScomponents/studio/CharacterCount.tsx
TypeScript
// 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

TSactions/publishAction.ts
TypeScript
// 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

TSsanity.config.ts
TypeScript
// 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

TSapp/api/preview/route.ts
TypeScript
// 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

TSlib/sanity.ts
TypeScript
// 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

TSapp/api/revalidate/route.ts
TypeScript
// 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

Code
TEXT
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-secret

Cennik

Plany

Code
TEXT
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 support

Overages

Code
TEXT
API requests: $0.05 per 1000 after limit
Bandwidth: $0.10 per GB after limit
Assets: $0.50 per GB/month after limit

FAQ - 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?

  1. Eksportuj content z WP jako JSON
  2. Napisz skrypt transformujący na Sanity schema
  3. Użyj Sanity CLI do importu
  4. 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

  1. Structured Content - Content as data, not text blocks
  2. GROQ - Powerful query language built for content
  3. Real-time collaboration - Editing in real time
  4. Customizable Studio - Full control over the editor
  5. Portable Text - Rich text as JSON
  6. Image pipeline - On-the-fly image transformations
  7. TypeScript-first - Full schema typing
  8. Generous free tier - 100k API requests/month

Sanity vs Contentful vs Strapi

FeatureSanityContentfulStrapi
HostingCloudCloudSelf-hosted
Query languageGROQGraphQLREST/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 tier100k req/mo1M req/mo (limited)Free (self-host)
Open sourceStudio only✅ Full

Getting started with Sanity

Creating a project

Code
Bash
# 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:3333

Project structure

Code
TEXT
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.json

Schema definition

Basic schema

TSschemas/post.ts
TypeScript
// 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

TSschemas/author.ts
TypeScript
// 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)

TSschemas/blockContent.ts
TypeScript
// 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

TSschemas/index.ts
TypeScript
// 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]
TSsanity.config.ts
TypeScript
// 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

Code
GROQ
// 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

Code
GROQ
// 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

TSlib/sanity.ts
TypeScript
// 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

Code
Bash
npm install @sanity/client @sanity/image-url next-sanity

Configuration

TSlib/sanity.ts
TypeScript
// 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

TSapp/blog/page.tsx
TypeScript
// 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

TSapp/blog/[slug]/page.tsx
TypeScript
// 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

TScomponents/PortableTextComponents.tsx
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
// 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

TScomponents/SanityImage.tsx
TypeScript
// 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

TSsanity.config.ts
TypeScript
// 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

TScomponents/studio/CharacterCount.tsx
TypeScript
// 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

TSactions/publishAction.ts
TypeScript
// 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

TSsanity.config.ts
TypeScript
// 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

TSapp/api/preview/route.ts
TypeScript
// 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

TSlib/sanity.ts
TypeScript
// 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

TSapp/api/revalidate/route.ts
TypeScript
// 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

Code
TEXT
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-secret

Pricing

Plans

Code
TEXT
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 support

Overages

Code
TEXT
API requests: $0.05 per 1000 after limit
Bandwidth: $0.10 per GB after limit
Assets: $0.50 per GB/month after limit

FAQ - 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?

  1. Export content from WP as JSON
  2. Write a script to transform it into a Sanity schema
  3. Use the Sanity CLI to import the data
  4. 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.