Building with sanity visual editing

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.

1 / 2
Visual editing in sanity cms

Kliknij, aby powiększyć

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' i stega: 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-sanity na 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

typescript
// 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ę.

typescript
// 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 published i stega: false
  • W preview zawsze drafts i stega: true
  • Perspektywa pochodzi albo z headera (ustawianego przez middleware), albo z cookies Draft Mode
  • sanityLiveFetch zamiast client.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.

typescript
// 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:

typescript
// 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

typescript
// 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.

typescript
// 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:

typescript
// 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:

typescript
// 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ć:

Bash
*[_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

typescript
// 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.initial w sanity.config.ts (powinien być originem, nie routem API)
  • Origin aplikacji nie jest w allowOrigins

Iframe ładuje się, ale brak ramek

  • stega: false w fetch preview (najczęstsza przyczyna)
  • perspective: 'published' zamiast 'drafts'
  • Tekst czyszczony przez stegaClean przed renderem do DOM
  • Tekst animowany – stega nie dociera do finalnego DOM

Klik wybiera dokument, ale nie pole

  • Brak path w data-sanity
  • Ścieżka nie uwzględnia locale (powinno być en.title, nie title)
  • Tablica idzie po indeksie, nie po _key

Drafty nie są widoczne

  • SANITY_API_READ_TOKEN bez 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.enable ustawione 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.

TSX
// Ź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.

1 / 1
Sanity CMS

Kliknij, aby powiększyć

Dlaczego zewnętrzne Studio wciąż rzuca błędem 'Unable to connect to visual editing'? (Pułapka ciasteczek w iframe)

To jeden z tych problemów, który nie pojawia się w oficjalnej dokumentacji, a mimo to niezawodnie daje o sobie znać w momencie, gdy hostujesz Sanity Studio na osobnej domenie — na przykład na *.sanity.studio — zamiast trzymać je razem z aplikacją Next.js. Żeby go zrozumieć, trzeba spojrzeć na to, jak współczesne przeglądarki obsługują ciasteczka wewnątrz iframe'ów.

1. Problem z ciasteczkami wewnątrz iframe

Kiedy Sanity Studio jest hostowane na innej domenie (lub subdomenie) niż docelowa aplikacja Next.js, Presentation Tool otwiera twoją aplikację wewnątrz elementu iframe. Współczesne przeglądarki — szczególnie Safari z ITP, ale też coraz częściej Chrome — bezlitośnie blokują przesyłanie ciasteczek SameSite=Lax w takich cross-originowych ramkach. Oznacza to, że ciasteczko trybu draftMode z Next.js po prostu tam nie dociera, a funkcja draftMode().isEnabled po stronie serwera zwraca false.

2. Ślepa uliczka oficjalnej dokumentacji

Oficjalne poradniki Sanity zalecają warunkowe renderowanie komponentu na serwerze:

TSX
{(await draftMode()).isEnabled && <VisualEditing />}

Ponieważ ciasteczka są zablokowane i isEnabled zwraca false, komponent <VisualEditing /> w ogóle nie jest renderowany w drzewie DOM wewnątrz iframe'a. Kiedy Sanity Studio próbuje nawiązać połączenie (handshake przez postMessage), nie ma na stronie elementu, który mógłby mu odpowiedzieć. Skutkuje to błędem: "Unable to connect to visual editing".

3. Skutki uboczne bezwarunkowego renderowania

Prymitywnym obejściem jest usunięcie warunku i wpisanie na sztywno <VisualEditing /> w layoucie. Rozwiązuje to problem komunikacji w Studio (handshake działa poprawnie), ale rodzi ogromny problem UX: pływający pasek narzędzi (ikony Vercel Toolbar lub edycji) zaczyna pojawiać się na publicznej stronie — na localhost lub w produkcji, gdy użytkownik jest zalogowany albo ma starą sesję. To jest absolutnie niepożądane.

4. Złoty środek — VisualEditingWrapper (rozwiązanie)

Rozwiązaniem jest dedykowany komponent kliencki, który bezwarunkowo renderuje element (zapewniając poprawne działanie handshake'u), ale korzysta z oficjalnego hooka useIsPresentationTool(), aby za pomocą globalnego stylu CSS ukryć UI edycji przed osobami odwiedzającymi stronę poza Presentation Tool:

TSX
'use client'

import { VisualEditing } from 'next-sanity/visual-editing'
import { useIsPresentationTool } from 'next-sanity/hooks'
import { useEffect } from 'react'

export default function VisualEditingWrapper() {
  const isPresentationTool = useIsPresentationTool()

  // Zawsze ładujemy VisualEditing, aby odpowiadał na handshake iframa (omija problem z cookie).
  // Jeśli jednak nie jesteśmy w trybie Presentation Tool, chowamy cały UI za pomocą CSS.
  useEffect(() => {
    if (!isPresentationTool) {
      const style = document.createElement('style')
      style.innerHTML = `
        sanity-visual-editing { display: none !important; }
      `
      document.head.appendChild(style)
      return () => {
        document.head.removeChild(style)
      }
    }
  }, [isPresentationTool])

  return <VisualEditing />
}

Następnie w layout.tsx wystarczy podpiąć <VisualEditingWrapper /> bez żadnych warunków draftMode. Komponent zajmuje się wszystkim: jest zawsze obecny w DOM na potrzeby handshake'u, a przed zwykłymi odwiedzającymi chowa się za pomocą reguły CSS, która odpala się tylko wtedy, gdy strona nie jest załadowana wewnątrz Presentation Tool.

To wiedza, która pochodzi wyłącznie z praktycznej walki z ograniczeniami przeglądarek — nie znajdziesz jej w oficjalnej dokumentacji, a zaoszczędzi ci wiele godzin debugowania.

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.

FAQ

Najczęściej zadawane pytania

Stega source maps to niewidoczne znaki Unicode osadzone bezpośrednio w ciągach tekstowych zwracanych przez klienta Sanity. Każdy zakodowany ciąg zawiera informacje o ID projektu, datasecie, ID dokumentu i ścieżce pola, z którego pochodzi. Kiedy najedziesz myszą na element DOM w iframe Presentation, biblioteka @sanity/visual-editing odczytuje te ukryte znaki i wie dokładnie, z którego pola Sanity pochodzi dany tekst. To właśnie umożliwia nakładkę click-to-edit — bez stegi biblioteka nie ma możliwości zmapowania wyrenderowanego ciągu z powrotem na pole źródłowe.

Najczęstsza przyczyna to wyłączona stega w preview fetchu — jeśli stega: false jest przekazane (lub domyślne) przy pobieraniu danych w trybie podglądu, ciągi nie zawierają metadanych, które biblioteka nakładek mogłaby odczytać. Powiązany problem to fetchowanie z perspective: 'published' zamiast 'drafts', przez co drafty i ich kodowanie stega nigdy nie trafiają na stronę. Możliwe też, że stosujesz stegaClean() na tekście przed renderem do DOM, co usuwa metadane Unicode. Na koniec — jeśli tekst jest animowany przez GSAP lub podobne biblioteki, silnik animacji często zastępuje surowe znaki ciągu, niszcząc osadzone dane stega zanim zostaną odczytane.

Draft Mode w Next.js opiera się na cookies, ale przeglądarki w trybie strict third-party cookie blocking — domyślnym w Safari i coraz bardziej wymuszanym w Chrome — blokują te cookies, gdy aplikacja jest załadowana w iframe Presentation Sanity. Oznacza to, że draftMode().isEnabled zwraca false w Server Components, więc fetche wracają do opublikowanej treści z wyłączoną stegą. Middleware rozwiązuje ten problem, sprawdzając każde przychodzące żądanie pod kątem sygnałów, że pochodzi z iframe Sanity (query params, cookies lub nagłówek Referer wskazujący na *.sanity.studio), a następnie wstrzykując nagłówki HTTP x-sanity-presentation-preview i x-sanity-preview-perspective. Twój fetch helper odczytuje te nagłówki jako fallback niezależny od cookies, żeby aktywować perspektywę draftów i kodowanie stega.

Gdy aplikacja Next.js używa routingu z prefiksem [lang] (np. /en, /pl), Sanity Studio może próbować włączyć Draft Mode wywołując /en/api/draft-mode/enable — ale faktyczny route API znajduje się pod /api/draft-mode/enable, więc żądanie zwraca 404 i Draft Mode nigdy się nie włącza. Rozwiązaniem jest dodanie zlokalizowanych fallback handlerów pod src/app/[lang]/api/draft-mode/enable/route.ts (i analogicznego route'u disable), które wyciągają locale z URL i delegują do współdzielonego helpera enableDraftMode. Bez tych dwóch plików każdy projekt z routingiem i18n będzie po cichu nie wchodzić w tryb podglądu przy nawigacji z lokalizowanego URL w Presentation.

Automatyczna stega działa dobrze przy prostym renderowaniu ciągów, ale zawodzi w kilku scenariuszach, gdzie trzeba dodać atrybut data-sanity ręcznie. Jeśli tekst jest animowany przez GSAP lub podobne biblioteki, silnik animacji zastępuje surowe znaki i niszczy osadzone metadane Unicode. To samo dzieje się, gdy tekst jest przetwarzany przez .split() lub .replace() przed renderem, albo gdy zapytanie GROQ używa coalesce(select(...)) w sposób powodujący utratę kontekstu pola przez stegę. Ręcznych atrybutów data-sanity potrzebujesz też dla elementów w tablicach z dynamicznym _key oraz dla każdego Client Component, który transformuje wartość przed dotarciem do DOM.

Każde zapytanie zasilające komponent z Visual Editing musi zwracać _id i _type na poziomie dokumentu — bez nich helper sanityDataAttribute nie ma co zakodować w linku edit intent, więc kliknięcia nie mogą otworzyć właściwego dokumentu w Studio. Dla pól tablicowych każdy element musi zawierać _key w projekcji. Jeśli _key jest pominięty i fallbackujesz na indeks numeryczny, zmiana kolejności elementów w Studio spowoduje, że kliknięcia będą trafiać w zły element, bo indeks nie odpowiada już oryginalnej pozycji.

To problem z ciasteczkami cross-origin. Gdy Studio jest hostowane na innej domenie (np. *.sanity.studio) niż aplikacja Next.js, Presentation Tool ładuje aplikację w iframe. Współczesne przeglądarki — szczególnie Safari z ITP — blokują ciasteczka SameSite=Lax w cross-originowych iframe'ach, przez co draftMode().isEnabled zwraca false na serwerze. Oficjalny wzorzec warunkowego renderowania {(await draftMode()).isEnabled && <VisualEditing />} sprawia, że komponent nigdy nie trafia do DOM, więc handshake postMessage ze Studio nie ma czego odpowiedzieć. Rozwiązaniem jest komponent kliencki VisualEditingWrapper, który zawsze renderuje <VisualEditing /> (zapewniając działanie handshake'u), ale korzysta z hooka useIsPresentationTool(), żeby wstrzyknąć regułę CSS ukrywającą UI edycji, gdy strona nie jest załadowana wewnątrz Presentation Tool.