Sanity to headless CMS, w którym treść definiujesz schematem w kodzie, a pobierasz przez GROQ. Samo w sobie proste. Presentation Tool i Visual Editing to osobna historia – to funkcjonalność, dzięki której klient lub redaktor może kliknąć bezpośrednio w dowolny tekst na stronie i od razu zobaczyć to konkretne pole otwarte w panelu Studio. Bez szukania dokumentu na liście, bez zgadywania, które pole odpowiada za który nagłówek. Jedno kliknięcie i edytujesz.
Implementowałem je w kilku projektach (np. “Interaktywne portfolio grafika”): przy różnych strukturach dokumentów, z i18n po dwóch językach, ze singletonami i dynamicznymi kolekcjami. Przy każdym projekcie pojawiały się te same lub podobne problemy – iframe ładuje się, strona wygląda normalnie, ale hover nic nie podświetla, klik nic nie wybiera, a w konsoli Studio siedzi Unable to connect to visual editing. Z tych doświadczeń powstał wewnętrzny blueprint, który trzymam i aktualizuję po każdej kolejnej implementacji.
Ten post powstał na bazie tego blueprintu. Nie przepisuję dokumentacji Sanity – skupiam się na tym, czego tam nie ma: na konkretnych problemach, ich przyczynach i rozwiązaniach, które faktycznie zadziałały w moim przypadku.
Bo z dokumentacją techniczną bywa jak w klasycznym memie:
Przykład na zajęciach: 1+1=2.
Przykład na egzaminie: Masz 4 jabłka. Jedno zjadłeś a drugie oddałeś koledze. Oblicz masę słońca.
Czym właściwie jest Visual Editing
Zanim przejdę do kodu, jedno wyjaśnienie, bo bez niego łatwo debugować nie to, co trzeba.
Sanity Visual Editing to nie jest po prostu "iframe z Sanity Studio".
Prawdziwe Visual Editing działa tak: klikasz na tekst w podglądzie, a Studio po prawej automatycznie otwiera odpowiedni dokument i skacze dokładnie do tego pola.
Żeby działało, Sanity osadza w renderowanych ciągach tekstowych niewidoczne metadane – tak zwane stega source maps. To znaki Unicode z zakodowaną informacją: jaki projekt, jaki dataset, jaki dokument, jaka ścieżka pola. Kiedy najedziesz myszą na element DOM, biblioteka @sanity/visual-editing odczytuje te metadane i wie, skąd ten tekst pochodzi.
Alternatywnie możesz ręcznie dodać atrybut data-sanity do elementu DOM, który zakoduje te same informacje wprost w HTML.
Jeśli iframe się ładuje, ale nie ma ramek ani podświetleń przy hoverze – brakuje właśnie tego: albo stega, albo data-sanity. Sama obecność <VisualEditing /> w layoucie to za mało.

Ile ruchomych części
Kompletna implementacja składa się z:
- Klienta Sanity ze skonfigurowanym
stega.studioUrl - Sanity Live (
defineLive) do odświeżania podglądu po zmianach - Draft Mode – API route, który włącza/wyłącza podgląd draftów
- Fetch helpera, który w preview przełącza na
perspective: 'drafts'istega: true - Middleware/proxy, który rozpoznaje requesty z Sanity iframe i ustawia odpowiednie headery
<VisualEditing />i<SanityLive />w root layoucie- Presentation resolvera – mapuje URL-e aplikacji na typy dokumentów
data-sanityna renderowanych elementach DOM
Każdy z tych punktów jest potrzebny. Pominięcie któregokolwiek skutkuje innym objawem – i to właśnie sprawia, że diagnoza bywa frustrująca.
Klient i stega
// src/sanity/lib/client.ts
import { createClient } from 'next-sanity'
export const studioUrl =
process.env.NEXT_PUBLIC_SANITY_STUDIO_URL ||
'https://your-studio.sanity.studio'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID || '',
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2026-02-01',
useCdn: true,
perspective: 'published',
stega: {
studioUrl,
},
})stega.studioUrl ustawiasz raz w konfiguracji klienta – określa, gdzie Studio jest zahostowane, żeby source mapy zawierały poprawne linki edit intent. W preview fetchu nie powtarzasz studioUrl, ale musisz aktywować kodowanie przez stega: true – o tym za chwilę.
Fetch helper – tu dzieje się magia przełączania
To jest centralne miejsce implementacji. Jeden helper, który w buildzie i produkcji zachowuje się jak normalny klient, a w preview przełącza na drafty i stegę.
// src/sanity/lib/fetch.ts
import 'server-only'
import { PHASE_PRODUCTION_BUILD } from 'next/constants'
import { cookies, draftMode, headers } from 'next/headers'
import { resolvePerspectiveFromCookies } from 'next-sanity/live'
import { sanityLiveFetch } from './live'
async function resolveLiveFetchOptions() {
if (process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD) {
return { perspective: 'published' as const, stega: false }
}
const [{ isEnabled }, headerStore] = await Promise.all([
draftMode(),
headers(),
])
const hasPreviewHeader =
headerStore.get('x-sanity-presentation-preview') === '1'
const headerPerspective = headerStore.get('x-sanity-preview-perspective')
if (!isEnabled && !hasPreviewHeader && !headerPerspective) {
return { perspective: 'published' as const, stega: false }
}
const perspective =
headerPerspective && headerPerspective !== 'raw'
? (headerPerspective as 'drafts' | 'previewDrafts')
: (await resolvePerspectiveFromCookies({ cookies: await cookies() })) ??
'drafts'
return { perspective, stega: true }
}
export async function sanityFetch<T>({
query,
params = {},
tags = ['sanity'],
}: {
query: string
params?: Record<string, unknown>
tags?: string[]
}): Promise<T> {
const { data } = await sanityLiveFetch({
query,
params,
tags,
...(await resolveLiveFetchOptions()),
})
return data as T
}Kluczowe zasady:
- W buildzie zawsze
publishedistega: false - W preview zawsze
draftsistega: true - Perspektywa pochodzi albo z headera (ustawianego przez middleware), albo z cookies Draft Mode
sanityLiveFetchzamiastclient.fetch– dzięki temu Sanity Live może odświeżać widok po mutacjach
Problem z cookies w iframe i dlaczego potrzebujesz middleware
To jest najczęstsze źródło problemów przy wdrożeniach na produkcję.
Draft Mode w Next.js działa przez cookies. Sanity Presentation otwiera Twoją aplikację w iframe. Przeglądarki w trybie strict third-party cookie blocking (domyślny w Safari, coraz bardziej restrykcyjny w Chrome) mogą blokować te cookies w kontekście iframe. Efekt: Draft Mode cookie nie dociera do Server Components, draftMode().isEnabled zwraca false, fetch idzie przez published, stega jest wyłączona, ramek nie ma.
Rozwiązanie: middleware, który rozpoznaje requesty z iframe Sanity i dodaje do nich własne headery HTTP – niezależnie od cookies.
// middleware.ts (fragment)
function isSanityReferer(request: NextRequest) {
const referer = request.headers.get('referer')
if (!referer) return false
try {
const { hostname } = new URL(referer)
return (
hostname === 'sanity.io' ||
hostname.endsWith('.sanity.io') ||
hostname.endsWith('.sanity.studio')
)
} catch {
return false
}
}
function isSanityPreviewRequest(request: NextRequest) {
const url = request.nextUrl
return (
url.searchParams.has('sanity-preview-perspective') ||
url.searchParams.has('sanity-preview-secret') ||
Boolean(request.cookies.get('sanity-preview-perspective')?.value) ||
isSanityReferer(request)
)
}
function withSanityPreviewHeaders(request: NextRequest) {
if (!isSanityPreviewRequest(request)) {
return NextResponse.next()
}
const requestHeaders = new Headers(request.headers)
const perspective =
request.nextUrl.searchParams.get('sanity-preview-perspective') ||
request.cookies.get('sanity-preview-perspective')?.value ||
'drafts'
requestHeaders.set('x-sanity-presentation-preview', '1')
requestHeaders.set('x-sanity-preview-perspective', perspective)
return NextResponse.next({ request: { headers: requestHeaders } })
}Ważne: referer ze Studio jest dobrym sygnałem nawet kiedy query string zniknie po pierwszym przekierowaniu. Nie pomijaj tej gałęzi.
Po stronie Server Components sprawdzasz oba sygnały:
// src/sanity/preview.ts
export async function isSanityPreviewRequest() {
const [{ isEnabled }, headerStore] = await Promise.all([
draftMode(),
headers(),
])
return isEnabled || headerStore.get('x-sanity-presentation-preview') === '1'
}Draft Mode API route – i lokalizowana pułapka
// src/app/api/draft-mode/enable/route.ts
import { enableDraftMode } from '@/sanity/draft-mode'
export async function GET(request: Request) {
return enableDraftMode(request)
}Nic skomplikowanego. Problem pojawia się przy i18n.
Jeśli aplikacja ma routing oparty na [lang] (np. /en, /pl) i użytkownik w Presentation kliknie link prowadzący do /en/jakasstrona, Studio może spróbować wywołać Draft Mode pod /en/api/draft-mode/enable. Ten route nie istnieje – dostajesz 404, Draft Mode się nie włącza.
Rozwiązanie: dodaj lokalizowane fallbacki.
// src/app/[lang]/api/draft-mode/enable/route.ts
import { getLocale } from '@/lib/i18n'
import { enableDraftMode } from '@/sanity/draft-mode'
export async function GET(request: Request) {
const lang = getLocale(new URL(request.url).pathname.split('/')[1])
return enableDraftMode(request, `/${lang}`)
}
To samo dla disable. Dwa pliki, ale bez nich w projekcie z i18n będziesz debugować 404, którego przyczyna na pierwszy rzut oka nie jest oczywista.
Jawne data-sanity – kiedy stega nie wystarczy
Automatyczna stega w ciągach tekstowych działa dobrze dla prostych przypadków. Przestaje działać, gdy:
- tekst jest animowany (biblioteki GSAP itp. zastępują znaki Unicode)
- tekst jest dzielony przez
.split()lub.replace() - tekst pochodzi z
coalesce(select(...))w GROQ i stega gubi kontekst - element jest w tablicy obiektów z dynamicznym
_key - komponent jest Client Component i modyfikuje wartość przed renderem
W tych sytuacjach dodajesz data-sanity ręcznie.
Helper:
// src/sanity/visual-editing.ts
import { createDataAttribute } from 'next-sanity'
import { dataset, projectId, studioUrl } from '@/sanity/lib/client'
import type { Locale } from '@/lib/i18n'
export function localizedSanityPath(locale: Locale, path: string | unknown[]) {
return Array.isArray(path) ? [locale, ...path] : [locale, path]
}
export function keyedPath(key: string | undefined, fallbackIndex: number) {
return key ? [{ _key: key }] : [fallbackIndex]
}
export function sanityDataAttribute({
id,
type,
path,
}: {
id?: string
type?: string
path: string | unknown[]
}) {
if (!id || !type) return undefined
return createDataAttribute({
baseUrl: studioUrl,
dataset,
projectId,
id,
type,
path,
}).toString()
}Użycie w komponencie:
// Singleton (np. strona główna)
const editAttr = (path: string | unknown[]) =>
sanityDataAttribute({
id: data?._id || 'homePage',
type: data?._type || 'homePage',
path: localizedSanityPath(locale, path),
})
<h1 data-sanity={editAttr('heroTitle')}>{data?.heroTitle}</h1>
// Tablica obiektów
{study.process.map((step, index) => (
<p
key={step._key || index}
data-sanity={sanityDataAttribute({
id: study._id,
type: 'caseStudy',
path: localizedSanityPath(locale, [
'process',
...keyedPath(step._key, index),
'description',
]),
})}
>
{step.description}
</p>
))}Kluczowe: tablice muszą iść po _key, nie po indeksie. Jeśli użytkownik przestawi kolejność elementów w Studio, indeks numeryczny wskaże zły element. _key jest stabilny.
GROQ queries – co musi wracać
Każde query dla komponentu z Visual Editing musi zwracać:
*[_type == "caseStudy" && slug.current == $slug][0] {
_id,
_type,
"slug": slug.current,
"title": coalesce(select($lang == "pl" => pl.title, en.title), title),
"process": coalesce(
select($lang == "pl" => pl.process[] { _key, number, title, description },
en.process[] { _key, number, title, description }),
process[] { _key, number, title, description }
)
}
Bez _id i _type helper sanityDataAttribute nie ma co zakodować w atrybucie. Bez _key w tablicach kliknięcie może trafić w zły element po zmianie kolejności.
Presentation resolver – ścieżki muszą być dokładne
// src/sanity/presentation/resolve.ts
const mainDocuments = defineDocuments([
{
route: ['/', '/en', '/pl'],
type: 'homePage',
},
{
route: ['/en/about', '/pl/about'],
type: 'aboutPage',
},
{
route: ['/en/case/:slug', '/pl/case/:slug'],
filter: `_type == "caseStudy" && slug.current == $slug`,
},
])Ścieżki w resolverze muszą dokładnie odpowiadać realnemu routingowi Next.js. Jeśli fizyczny route to src/app/[lang]/case/[slug]/page.tsx, to URL jest /en/case/my-slug – resolver nie może wskazywać /case/my-slug.
To też jest częsty powód 404 w podglądzie. Presentation próbuje otworzyć URL z resolvera, a ten nie istnieje w aplikacji.
Najczęstsze objawy i przyczyny
Unable to connect to visual editing
- Brak
<VisualEditing />w layoucie - Zły
previewUrl.initialwsanity.config.ts(powinien być originem, nie routem API) - Origin aplikacji nie jest w
allowOrigins
Iframe ładuje się, ale brak ramek
stega: falsew fetch preview (najczęstsza przyczyna)perspective: 'published'zamiast'drafts'- Tekst czyszczony przez
stegaCleanprzed renderem do DOM - Tekst animowany – stega nie dociera do finalnego DOM
Klik wybiera dokument, ale nie pole
- Brak
pathwdata-sanity - Ścieżka nie uwzględnia locale (powinno być
en.title, nietitle) - Tablica idzie po indeksie, nie po
_key
Drafty nie są widoczne
SANITY_API_READ_TOKENbez dostępu do draftów- Middleware nie ustawia preview headerów
- Cookies blokowane w iframe i brak fallbacku przez headery
404 na /en/api/draft-mode/enable
- Brak lokalizowanego fallback route
previewMode.enableustawione na URL z prefiksem locale
Tego nie rób
Jeden błąd, który widziałem w kilku projektach: stegaClean na tekstach widocznych w DOM.
// ŹLE
<h1>{stegaClean(data?.heroTitle)}</h1>
// DOBRZE
<h1>{data?.heroTitle}</h1>stegaClean usuwa metadane stega z ciągu tekstowego. Używaj go tylko dla: slugów w URL, metadanych <head>, wartości przekazywanych do zewnętrznych bibliotek (np. animacje, mapy), gdzie ukryte znaki Unicode mogą powodować problemy. Na elementach renderowanych do DOM – stegę zostawiasz.

Czy to w ogóle warte zachodu?
Krótka odpowiedź: tak, ale nie dla każdego projektu i nie dla każdego klienta.
Sanity Visual Editing ma sens tam, gdzie treść jest edytowana regularnie, przez kogoś kto nie zna struktury CMS i nie chce się jej uczyć. Zamiast szukać odpowiedniego dokumentu na liście, a w nim odpowiedniego pola – klient klika w tekst na stronie i edytuje. Mniej maili z pytaniem "gdzie zmienić ten nagłówek", mniej frustracji przy aktualizacjach. Dla agencji to też argument sprzedażowy: możesz pokazać klientowi działający podgląd jeszcze przed podpisaniem odbioru.
Problemem jest to, że implementacja jest nieproporcjonalnie skomplikowana w stosunku do tego, co widać na zewnątrz. Każdy z tych elementów może po cichu przestać działać – bez błędu w konsoli, bez żadnej wskazówki. Iframe załaduje się, strona wygląda normalnie. Dopiero hover bez ramki i klik bez reakcji mówią ci, że coś nie gra.
Dlatego warto zbudować solidną konfigurację raz i trzymać ją jako gotowy blueprint do kolejnych projektów. Ten post jest właśnie tym dla mnie – i mam nadzieję, że komuś oszczędzi kilku - kilkunastu godzin debugowania.


