Sanity CMS + Next.js — od instalacji po live preview i Visual Editing

Jak zintegrować Sanity CMS z Next.js App Router? Schema, GROQ queries, ISR, live preview, Visual Editing i deploy — kompletny setup headless CMS dla strony usługowej.

Opublikowano

11 kwietnia 2026 08:20

Czytanie

4 min czytania

Aktualizacja

13 kwietnia 2026 18:56

Dlaczego Sanity?

Sanity to headless CMS z edycją w czasie rzeczywistym, schematem definiowanym w kodzie i dopracowaną integracją z Next.js. W porównaniu z Contentful wyróżnia się schema-as-code — typy treści definiujesz w TypeScript, nie przez GUI, więc schema żyje w repozytorium razem z kodem. W porównaniu ze Strapi nie wymaga własnego serwera i bazy danych — Sanity hostuje wszystko za Ciebie.

Dla stron usługowych w Next.js — Sanity pozwala klientowi edytować treści (teksty, zdjęcia, meta tagi, FAQ) bez Twojej pomocy.

Setup

Code
# Inicjalizacja Sanity w projekcie Next.js
npm create sanity@latest -- --project-id xxx --dataset production --template clean
npm install next-sanity @sanity/image-url @portabletext/react

Sanity Studio (panel admina) działa jako route w Twojej aplikacji Next.js — na /studio.

Zmienne środowiskowe

Code
# .env.local
NEXT_PUBLIC_SANITY_PROJECT_ID=twoje_project_id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_READ_TOKEN=twoj_token_z_manage_sanity_io
SANITY_WEBHOOK_SECRET=losowy_string_do_webhookow
SANITY_PREVIEW_SECRET=losowy_string_do_draft_mode

SANITY_API_READ_TOKEN generujesz w manage.sanity.io → API → Tokens → dodaj token z uprawnieniami Viewer.

Konfiguracja klienta

Code
// lib/sanity/client.ts
import { createClient } from 'next-sanity'
 
const config = {
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
  apiVersion: '2026-04-11',
}
 
// Klient produkcyjny (CDN, publiczne dane)
export const client = createClient({
  ...config,
  useCdn: process.env.NODE_ENV === 'production',
})
 
// Klient do podglądu (token, brak CDN, drafty)
export const previewClient = createClient({
  ...config,
  useCdn: false,
  token: process.env.SANITY_API_READ_TOKEN,
  perspective: 'previewDrafts',
})

Przy draft mode, preview i Visual Editing używaj previewClient — widzi najnowsze drafty zamiast danych z CDN.

Pomocnik urlFor

Code
// lib/sanity/image.ts
import imageUrlBuilder from '@sanity/image-url'
import { client } from './client'
 
const builder = imageUrlBuilder(client)
 
export function urlFor(source: Parameters<typeof builder.image>[0]) {
  return builder.image(source)
}

Schema — definicja typów treści

Najpierw blockContent — typ wielokrotnego użytku dla pól Portable Text:

Code
// sanity/schemas/blockContent.ts
import { defineArrayMember, defineType } from 'sanity'
 
export const blockContentSchema = defineType({
  name: 'blockContent',
  type: 'array',
  of: [
    defineArrayMember({
      type: 'block',
      styles: [
        { title: 'Normalny', value: 'normal' },
        { title: 'H2', value: 'h2' },
        { title: 'H3', value: 'h3' },
        { title: 'Cytat', value: 'blockquote' },
      ],
      marks: {
        decorators: [
          { title: 'Pogrubienie', value: 'strong' },
          { title: 'Kursywa', value: 'em' },
          { title: 'Kod', value: 'code' },
        ],
        annotations: [
          {
            name: 'link',
            type: 'object',
            title: 'Link',
            fields: [{ name: 'href', type: 'url', title: 'URL' }],
          },
        ],
      },
    }),
    defineArrayMember({ type: 'image', options: { hotspot: true } }),
  ],
})

Następnie schema posta:

Code
// sanity/schemas/post.ts
import { defineType, defineField } from 'sanity'
 
export const postSchema = defineType({
  name: 'post',
  title: 'Artykuł',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Tytuł',
      type: 'string',
      validation: (Rule) => Rule.required().max(100),
    }),
    defineField({
      name: 'slug',
      title: 'Slug (URL)',
      type: 'slug',
      options: { source: 'title', maxLength: 96 },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'excerpt',
      title: 'Zajawka',
      type: 'text',
      rows: 3,
      validation: (Rule) => Rule.max(200),
    }),
    defineField({
      name: 'mainImage',
      title: 'Zdjęcie główne',
      type: 'image',
      options: { hotspot: true },
    }),
    defineField({
      name: 'body',
      title: 'Treść',
      type: 'blockContent', // Portable Text
    }),
    defineField({
      name: 'publishedAt',
      title: 'Data publikacji',
      type: 'datetime',
    }),
    defineField({
      name: 'tags',
      title: 'Tagi',
      type: 'array',
      of: [{ type: 'string' }],
      options: { layout: 'tags' },
    }),
    defineField({
      name: 'seo',
      title: 'SEO',
      type: 'object',
      fields: [
        defineField({ name: 'metaTitle', title: 'Meta Title', type: 'string' }),
        defineField({
          name: 'metaDescription',
          title: 'Meta Description',
          type: 'text',
          rows: 2,
        }),
      ],
    }),
  ],
  orderings: [
    {
      title: 'Data publikacji',
      name: 'publishedAtDesc',
      by: [{ field: 'publishedAt', direction: 'desc' }],
    },
  ],
  preview: {
    select: { title: 'title', media: 'mainImage', date: 'publishedAt' },
    prepare({ title, media, date }) {
      return {
        title,
        media,
        subtitle: date
          ? new Date(date).toLocaleDateString('pl-PL')
          : 'Brak daty',
      }
    },
  },
})

Oba typy zarejestruj w konfiguracji Sanity:

Code
// sanity.config.ts (fragment)
import { postSchema } from './sanity/schemas/post'
import { blockContentSchema } from './sanity/schemas/blockContent'
 
export default defineConfig({
  // ...
  schema: { types: [postSchema, blockContentSchema] },
})

Wygeneruj typy TypeScript ze schemy, żeby uniknąć any w komponentach:

Code
npx sanity typegen generate

Tworzy sanity.types.ts — importuj stamtąd typy zamiast pisać je ręcznie.

Zapytania GROQ

GROQ to język zapytań Sanity — podobny do GraphQL, ale prostszy. Każde zapytanie powinno deklarować cache tagi, żeby on-demand revalidation działała poprawnie:

Code
// lib/sanity/queries.ts
import { client } from './client'
import type { Post, PostSummary } from '@/sanity.types'
 
// Wszystkie opublikowane posty
export async function getPosts(): Promise<PostSummary[]> {
  return client.fetch(
    `*[_type == "post" && publishedAt < now()] | order(publishedAt desc) {
      _id,
      title,
      "slug": slug.current,
      excerpt,
      publishedAt,
      tags,
      mainImage
    }`,
    {},
    { next: { tags: ['posts'] } },
  )
}
 
// Wszystkie slugi (do generateStaticParams)
export async function getPostSlugs(): Promise<string[]> {
  const slugs = await client.fetch<{ slug: string }[]>(
    `*[_type == "post" && defined(slug.current)]{ "slug": slug.current }`,
    {},
    { next: { tags: ['posts'] } },
  )
  return slugs.map((s) => s.slug)
}
 
// Pojedynczy post po slug
export async function getPost(slug: string): Promise<Post | null> {
  return client.fetch(
    `*[_type == "post" && slug.current == $slug][0] {
      _id,
      title,
      "slug": slug.current,
      body,
      mainImage,
      publishedAt,
      tags,
      seo,
      "readingTime": round(length(pt::text(body)) / 5 / 200)
    }`,
    { slug },
    { next: { tags: ['posts', `post-${slug}`] } },
  )
}

Strona listingu blogowego

Code
// app/blog/page.tsx
import { getPosts } from '@/lib/sanity/queries'
import { urlFor } from '@/lib/sanity/image'
import type { PostSummary } from '@/sanity.types'
import Image from 'next/image'
import Link from 'next/link'
 
export const revalidate = 3600 // ISR — odśwież co godzinę (fallback)
 
export default async function BlogPage() {
  const posts = await getPosts()
 
  return (
    <main className="mx-auto max-w-4xl py-12">
      <h1 className="mb-8 text-3xl font-bold">Blog</h1>
      <div className="space-y-8">
        {posts.map((post: PostSummary) => (
          <Link
            key={post._id}
            href={`/blog/${post.slug}`}
            className="group block"
          >
            <article className="flex gap-6">
              {post.mainImage && (
                <Image
                  src={urlFor(post.mainImage).width(300).height(200).url()}
                  alt={post.title ?? ''}
                  width={300}
                  height={200}
                  className="rounded-lg object-cover"
                />
              )}
              <div>
                <h2 className="text-xl font-semibold group-hover:text-blue-600">
                  {post.title}
                </h2>
                <p className="mt-2 text-gray-600">{post.excerpt}</p>
                <time className="mt-2 block text-sm text-gray-400">
                  {new Date(post.publishedAt!).toLocaleDateString('pl-PL')}
                </time>
              </div>
            </article>
          </Link>
        ))}
      </div>
    </main>
  )
}

Strona pojedynczego posta

Code
// app/blog/[slug]/page.tsx
import { getPost, getPostSlugs } from '@/lib/sanity/queries'
import { urlFor } from '@/lib/sanity/image'
import { PortableText } from '@portabletext/react'
import type { Metadata } from 'next'
import Image from 'next/image'
import { notFound } from 'next/navigation'
 
type Props = { params: Promise<{ slug: string }> }
 
export async function generateStaticParams() {
  const slugs = await getPostSlugs()
  return slugs.map((slug) => ({ slug }))
}
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) return {}
 
  return {
    title: post.seo?.metaTitle ?? post.title,
    description: post.seo?.metaDescription ?? post.excerpt,
    openGraph: post.mainImage
      ? { images: [urlFor(post.mainImage).width(1200).height(630).url()] }
      : undefined,
  }
}
 
export default async function PostPage({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
  if (!post) notFound()
 
  return (
    <article className="mx-auto max-w-3xl py-12">
      <h1 className="mb-4 text-4xl font-bold">{post.title}</h1>
      <div className="mb-8 flex items-center gap-4 text-sm text-gray-500">
        {post.publishedAt && (
          <time>{new Date(post.publishedAt).toLocaleDateString('pl-PL')}</time>
        )}
        {post.readingTime && <span>{post.readingTime} min czytania</span>}
      </div>
      {post.mainImage && (
        <Image
          src={urlFor(post.mainImage).width(800).height(450).url()}
          alt={post.title ?? ''}
          width={800}
          height={450}
          className="mb-8 rounded-xl object-cover"
          priority
        />
      )}
      {post.body && (
        <div className="prose prose-lg">
          <PortableText value={post.body} />
        </div>
      )}
    </article>
  )
}

On-demand ISR z webhookami Sanity

Zamiast czekać na rewalidację czasową — odśwież stronę natychmiast po edycji w Sanity:

Code
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
import { parseBody } from 'next-sanity/webhook'
 
export async function POST(req: NextRequest) {
  try {
    const { isValidSignature, body } = await parseBody(
      req,
      process.env.SANITY_WEBHOOK_SECRET,
    )
 
    if (!isValidSignature) {
      return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
    }
 
    const { _type, slug } = body
 
    if (_type === 'post') {
      revalidateTag('posts')
      if (slug?.current) revalidateTag(`post-${slug.current}`)
    }
 
    return NextResponse.json({ revalidated: true })
  } catch (error) {
    return NextResponse.json({ error: 'Bad Request' }, { status: 400 })
  }
}

W panelu Sanity: Manage → API → Webhooks → dodaj URL (https://twoja-domena.pl/api/revalidate) i secret.

Draft Mode — włączanie podglądu

Draft Mode to mechanizm Next.js, który przełącza rendering z cache'owanych danych na dane na żywo z Sanity (drafty). Aby wszystko działało poprawnie, zazwyczaj potrzebujesz dwóch route'ów: jednego do włączania podglądu (enable) i drugiego do jego wyłączania (disable):

Code
// app/api/draft/enable/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'
 
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
 
  if (searchParams.get('secret') !== process.env.SANITY_PREVIEW_SECRET) {
    return new Response('Invalid token', { status: 401 })
  }
 
  const draft = await draftMode()
  draft.enable()
  redirect(searchParams.get('redirect') || '/')
}
Code
// app/api/draft/disable/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
 
export async function GET() {
  const draft = await draftMode()
  draft.disable()
  redirect('/')
}

W Sanity Studio skonfiguruj Presentation Tool z URL podglądu:

Code
// sanity.config.ts
import { presentationTool } from 'sanity/presentation'
 
export default defineConfig({
  // ...
  plugins: [
    presentationTool({
      previewUrl: {
        origin:
          process.env.SANITY_STUDIO_PREVIEW_URL || 'http://localhost:3000',
        draftMode: {
          enable: '/api/draft/enable',
        },
      },
    }),
  ],
})

Live Preview z Presentation Tool

Live Preview umożliwia podgląd zmian w czasie rzeczywistym i aby go zaimplementować, Next.js musi wejść w tryb Draft Mode, który pozwala pobierać nieopublikowane wersje robocze z Sanity. W tym trybie, zamiast domyślnego klienta, używamy previewClient:

Code
// app/blog/[slug]/page.tsx (fragment z draft mode)
import { draftMode } from 'next/headers'
import { client, previewClient } from '@/lib/sanity/client'
 
export default async function PostPage({ params }: Props) {
  const { slug } = await params
  const { isEnabled } = await draftMode()
 
  const sanityClient = isEnabled ? previewClient : client
  const post = await sanityClient.fetch(
    `*[_type == "post" && slug.current == $slug][0]{...}`,
    { slug },
    { next: { tags: [`post-${slug}`] } },
  )
 
  if (!post) notFound()
  // ... reszta komponentu
}

previewClient ma perspective: 'previewDrafts' — zwraca nieopublikowane zmiany. Dla pełnego preview na żywo z aktualizacjami w czasie rzeczywistym (bez przeładowania strony) użyj VisualEditing razem z @sanity/preview-kit — dane są wtedy synchronizowane przez WebSocket w momencie każdej zmiany w edytorze.

Visual Editing — edycja treści na żywej stronie

Sanity Visual Editing pozwala klientowi kliknąć dowolny element na stronie i edytować go inline, ma to miejsce bez otwierania panelu Studio:

Code
npm install @sanity/visual-editing
Code
// app/layout.tsx
import { VisualEditing } from 'next-sanity'
import { draftMode } from 'next/headers'
 
export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const { isEnabled } = await draftMode()
 
  return (
    <html lang="pl">
      <body>
        {children}
        {isEnabled && <VisualEditing />}
      </body>
    </html>
  )
}

Klient widzi stronę jak użytkownik, ale edytowalne elementy dostają overlay prowadzący do Sanity Studio lub Presentation Tool. Jeśli Studio jest osadzone w tej samej aplikacji, trzymaj jego route w osobnej grupie layoutów i nie renderuj VisualEditing nad panelem Studio.

Podsumowanie

Połączenie Sanity CMS z Next.js to rewelacyjny wybór dla stron opartych na treści: zyskujesz szybkość statycznej witryny, ale z możliwością natychmiastowej aktualizacji po każdej zmianie w panelu. Dodatkowo, klient może edytować teksty i zdjęcia, klikając je bezpośrednio na podglądzie strony, co jest niezwykle intuicyjne. Dla typowych stron firmowych, blogów czy portfolio, taki zestaw w zupełności wystarcza do wygodnego zarządzania treścią, bez potrzeby tworzenia dedykowanego panelu od podstaw.

Najczęściej zadawane pytania

Ile kosztuje Sanity?

Sanity posiada darmowy plan, ale limity zapytań, CDN, bandwidth i assetów zmieniają się wraz z cennikiem. Generalnie, przy małych stronach darmowy plan może wystarczyć, ale przy większym ruchu lub wielu redaktorach, powinieneś sprawdzić aktualne limity przed wyceną.

Sanity vs Strapi — co wybrać?

Sanity działa w chmurze i jest gotowe do użycia od razu, co oznacza mniej technicznej pracy po Twojej stronie. Oferuje nowoczesne funkcje, jak edycja treści bezpośrednio na stronie (Visual Editing), co jest bardzo wygodne dla klientów. Strapi z kolei instalujesz na własnym serwerze, co daje Ci pełną kontrolę nad danymi i infrastrukturą, ale z drugiej strony wymaga więcej konfiguracji. Sanity to wybór, jeśli cenisz wygodę i nowoczesny interfejs dla redaktorów, a Strapi, jeśli priorytetem jest pełna kontrola nad hostingiem i danymi.

Czy mogę użyć Sanity bez Studio w Next.js?

Tak, Studio to opcjonalny panel admina. Możesz korzystać z Sanity API bezpośrednio z Server Components, bez osadzania Studio w aplikacji, ale Studio w /studio to najwygodniejsze rozwiązanie dla klienta.

Pracuję z tym zawodowo.

Jeśli chcesz przełożyć ten temat na lepszą architekturę frontendu, uporządkować React lub Next.js i podnieść jakość pracy zespołu, skontaktuj się ze mną. Pomagam zamieniać wiedzę z artykułów w praktyczne decyzje technologiczne.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów