Настройка 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 у экспертов и избавьте редакторов от ограничений.







