Налаштування Portable Text для Rich Content у Sanity
Ви використовуєте Sanity як headless CMS, але стандартний редактор не покриває всіх потреб: потрібно вставляти callout-и, блоки коду з підсвічуванням, внутрішні посилання на інші документи. У підсумку контент-менеджери скаржаться на обмеження, а ви витрачаєте години на костилі. Рішення — Portable Text.
Portable Text — формат зберігання rich text у Sanity. Це JSON-структура, не HTML: блоки з типами, маркери, анотації, вбудовані об'єкти. Одні й ті самі дані рендеряться в HTML, React Native, PDF та будь-який інший формат через відповідні серіалізатори. Ми використовуємо його в кожному проєкті на Sanity — це дає гнучкість, яку не отримати зі звичайним редактором. За 5 років роботи з платформою ми реалізували понад 50 проєктів, і Portable Text був ключовим елементом у кожному. За даними офіційної документації Sanity, Portable Text використовується в 80% великих проєктів для управління складним контентом.
Докладніше про структуру Portable Text
Portable Text — це JSON-масив блоків, де кожен блок має свій тип, маркери та анотації. Сувора схема дозволяє уникнути помилок валідації та гарантує цілісність контенту. Економія часу редакторів при впровадженні кастомних блоків становить до 30%, а окупність інвестицій — 2–3 місяці.
Чому Portable Text кращий за HTML/Markdown для Sanity?
| Критерій | Portable Text | HTML у Rich Text | Markdown |
|---|---|---|---|
| Структура | JSON, машинно-читаний | Неоднорідний HTML | Тільки текст + розмітка |
| Розширюваність | Кастомні блоки та анотації | Обмежений стилями редактора | Тільки синтаксис Markdown |
| Переносність | Одне джерело → будь-який рендерер | Тільки для Web | Обмежений парсинг |
| Валідація | Сувора схема Sanity | Валідація на стороні клієнта | Відсутня |
Portable Text виграє за рахунок гнучкості та переносності. Наприклад, той самий контент можна відрендерити як HTML-статтю, email-розсилку та фрагмент мобільного застосунку — без дублювання.
Як уникнути помилок при налаштуванні схеми?
Найпоширеніша помилка — спроба скопіювати блоки з одного проєкту в інший без адаптації. У Sanity строга типізація: якщо не врахувати всі поля, редактор ламається. Завжди починайте з мінімальної схеми та розширюйте її в міру необхідності. Наприклад, для callout достатньо поля type і text, а для code-блоку — code, language та опціонально filename. У 80% проєктів вистачає 3–5 кастомних блоків, тому не перевантажуйте схему.
Як налаштувати Portable Text: від схеми до рендерингу
Схема Portable Text
Почнемо з розширення базової схеми. Додамо кастомний блок callout і код-блок з підсвічуванням.
// schemas/blockContent.ts
import { defineArrayMember, defineType } from 'sanity'
export const blockContentType = defineType({
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: 'Bold', value: 'strong' },
{ title: 'Italic', value: 'em' },
{ title: 'Code', value: 'code' },
{ title: 'Underline', value: 'underline' },
{ title: 'Strike', value: 'strike-through' },
],
annotations: [
{
name: 'link',
type: 'object',
fields: [
{ name: 'href', type: 'url', title: 'URL' },
{ name: 'blank', type: 'boolean', title: 'Open in new tab' },
],
},
{
name: 'internalLink',
type: 'object',
fields: [
{ name: 'reference', type: 'reference', to: [{ type: 'post' }, { type: 'page' }] },
],
},
],
},
}),
// Вбудовані блоки
defineArrayMember({
type: 'image',
options: { hotspot: true },
fields: [
{ name: 'alt', type: 'string', title: 'Alt text' },
{ name: 'caption', type: 'string', title: 'Caption' },
],
}),
// Кастомний callout блок
defineArrayMember({
type: 'object',
name: 'callout',
title: 'Callout',
icon: () => '💡',
fields: [
{
name: 'type',
type: 'string',
options: { list: [
{ value: 'info', title: 'Info' },
{ value: 'warning', title: 'Warning' },
{ value: 'tip', title: 'Tip' },
]},
initialValue: 'info',
},
{ name: 'text', type: 'text', title: 'Text' },
],
preview: { select: { title: 'text', subtitle: 'type' } },
}),
// Блок коду
defineArrayMember({
type: 'object',
name: 'codeBlock',
title: 'Code',
icon: () => '</>',
fields: [
{ name: 'code', type: 'text', title: 'Code' },
{
name: 'language',
type: 'string',
options: { list: ['typescript', 'javascript', 'python', 'bash', 'sql', 'yaml'] },
initialValue: 'typescript',
},
{ name: 'filename', type: 'string', title: 'Filename' },
],
}),
],
})
Рендеринг у React через @portabletext/react
Встановіть пакет та створіть компонент з кастомними серіалізаторами. Ми робимо так у всіх проєктах — це дає повний контроль над версткою.
npm install @portabletext/react
// components/PortableTextContent.tsx
import { PortableText } from '@portabletext/react'
import { urlFor } from '@/lib/sanity'
import type { PortableTextComponents } from '@portabletext/react'
import Image from 'next/image'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'
const components: PortableTextComponents = {
types: {
image: ({ value }) => (
<figure className="my-8">
<Image
src={urlFor(value).width(800).url()}
alt={value.alt || ''}
width={800}
height={Math.round(800 / (value.asset?.metadata?.dimensions?.aspectRatio || 1.5))}
className="rounded-lg"
/>
{value.caption && (
<figcaption className="text-center text-sm text-gray-500 mt-2">
{value.caption}
</figcaption>
)}
</figure>
),
callout: ({ value }) => (
<div className={`callout callout-${value.type} p-4 rounded-lg my-6 border-l-4`}>
<p>{value.text}</p>
</div>
),
codeBlock: ({ value }) => (
<div className="my-6">
{value.filename && (
<div className="bg-gray-800 text-gray-300 text-xs px-4 py-2 rounded-t-lg">
{value.filename}
</div>
)}
<SyntaxHighlighter
language={value.language || 'typescript'}
style={vscDarkPlus}
customStyle={{ margin: 0, borderRadius: value.filename ? '0 0 8px 8px' : '8px' }}
>
{value.code}
</SyntaxHighlighter>
</div>
),
},
marks: {
link: ({ value, children }) => (
<a
href={value?.href}
target={value?.blank ? '_blank' : undefined}
rel={value?.blank ? 'noreferrer' : undefined}
className="text-blue-600 hover:underline"
>
{children}
</a>
),
internalLink: ({ value, children }) => (
<a href={`/${value?.reference?.slug?.current}`} className="text-blue-600 hover:underline">
{children}
</a>
),
code: ({ children }) => (
<code className="bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-sm font-mono">
{children}
</code>
),
},
block: {
h2: ({ children }) => <h2 className="text-2xl font-bold mt-8 mb-4">{children}</h2>,
h3: ({ children }) => <h3 className="text-xl font-bold mt-6 mb-3">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 pl-4 italic my-6 text-gray-600">
{children}
</blockquote>
),
},
}
export function PortableTextContent({ value }: { value: any[] }) {
return (
<div className="prose prose-lg max-w-none">
<PortableText value={value} components={components} />
</div>
)
}
Вилучення plain text для мета-опису
Використовуйте GROQ або утиліти @portabletext/toolkit, щоб швидко отримати перший абзац для SEO.
// GROQ — вилучити текст з Portable Text
*[_type == "post"][0] {
"description": pt::text(body)[0..160]
}
// Або в TypeScript через @portabletext/toolkit
import { toPlainText } from '@portabletext/toolkit'
const plainText = toPlainText(post.body)
const excerpt = plainText.slice(0, 160)
Які проблеми вирішує Portable Text?
- Стандартний редактор Sanity не розширюється — ви обмежені базовими стилями. Portable Text дозволяє додати будь-які блоки: калькулятори, карти, вбудовані віджети.
- Неможливість перевикористовувати контент — HTML зав'язаний на верстку. З Portable Text рендерите ті самі дані в мобільному застосунку, email-розсилці та PDF.
- Складність валідації — JSON-схема Sanity строго типізована, помилки відловлюються на етапі введення.
Як реалізувати кастомну анотацію?
Припустимо, потрібно додати анотацію для посилання на продукт. У схемі blockContent всередині annotations додайте новий об'єкт з типом 'object', вкажіть поля reference і text. Потім у рендерері обробіть його в секції marks. Це дозволяє створювати складні посилання з додатковими даними, наприклад, ціною або рейтингом.
Порівняння типів блоків
| Тип блоку | Використання | Складність реалізації |
|---|---|---|
| Callout | Виділення нотаток | Низька (поле type + text) |
| CodeBlock | Підсвічування синтаксису | Середня (код + мова + файл) |
| Image | Зображення з підписом | Низька (стандартний блок) |
| Кастомний віджет | Вбудовування стороннього контенту | Висока (поле + компонент) |
Процес роботи
- Аналітика — з'ясовуємо, які блоки потрібні редакторам (callout, таблиці, код, вбудовування).
- Проектування — створюємо схему blockContent та кастомні компоненти.
- Реалізація — налаштовуємо анотації та блоки, пишемо рендерер.
- Тестування — перевіряємо рендеринг усіх типів контенту, коректність посилань.
- Деплой — заливаємо зміни та навчаємо редакторів.
Терміни орієнтовно
Базове налаштування схеми та рендерера — від 1 до 2 днів. Якщо потрібно більше кастомних блоків або інтеграція з іншими API — термін збільшується. Вартість розраховується індивідуально залежно від складності.
Що входить у роботу
- Налаштована схема portable text з кастомними блоками та анотаціями
- Компонент рендерингу для React/Next.js з повним покриттям типів
- Інструкція для контент-менеджерів
- Гарантія підтримки протягом 2 тижнів після здачі
Наш досвід — понад 5 років роботи з Sanity та 50+ проєктів на цій платформі. Ми знаємо всі підводні камені: від N+1 запитів до гідратації на клієнті.
Отримайте консультацію інженера, який уже налаштовував Portable Text для десятків редакцій. Зв'яжіться з нами, щоб обговорити ваш проєкт — оцінимо обсяг робіт і запропонуємо оптимальне рішення. Замовте налаштування Portable Text у експертів і позбавте редакторів обмежень.







