Sanity CMS в Next.js 15: SEO-лендинг с on-demand revalidation
Маркетолог хочет менять заголовок hero, FAQ и цены без деплоя. Разработчик хочет, чтобы Google видел полный HTML с первого запроса, а Lighthouse показывал LCP ниже 2,5 секунды. Классический WordPress решает первую задачу, но часто проигрывает по Core Web Vitals. Чистый Next.js с контентом в коде — быстро, но каждое изменение текста требует PR и сборки.
Sanity как headless CMS закрывает оба требования: контент редактируется в Studio, а Next.js 15 App Router отдаёт его через Server Components — HTML попадает в ответ сервера, JSON-LD и metadata генерируются из одного источника. Связка on-demand revalidation через webhook обновляет страницу за секунды после публикации, без полной пересборки и без SSR на каждый запрос.
В этой статье соберём production-лендинг: схема Sanity, fetching в RSC, generateMetadata, preview mode, webhook на Vercel и проверка SEO.
Зачем headless CMS на SEO-лендинге в 2026 году
Лендинг услуг живёт неделями, а не годами — A/B-тесты заголовков, сезонные акции, новые кейсы в блоке отзывов. Жёстко зашитый контент в .tsx-файлах создаёт узкое место: маркетинг зависит от разработки, а каждый текстовый правка проходит через CI.
Headless CMS отделяет контент от презентации. Sanity хранит структурированные документы (Portable Text для rich text, references для связей), а Next.js рендерит их в HTML на edge. Для SEO это критично:
| Критерий | Контент в коде | Sanity + Next.js 15 RSC |
|---|---|---|
| HTML для роботов | Полный (SSG) | Полный (SSG/ISR) |
| Скорость правок | Через деплой | Через Studio за минуты |
| Metadata из CMS | Вручную дублировать | generateMetadata из одного запроса |
| Core Web Vitals | Отлично | Отлично при правильном кэше |
| Preview черновиков | Нет | Draft Mode + Visual Editing |
Альтернативы — Contentful, Payload, Strapi — работают по тому же принципу. Sanity выделяется GROQ-запросами, встроенным CDN для assets и бесплатным tier для небольших проектов. Для лендинга с 5–10 секциями Sanity Studio разворачивается за час, а не за неделю.
Ключевое правило SEO: контент из CMS должен рендериться на сервере, не через useEffect. Client-side fetch оставляет роботу пустую страницу и убивает LCP.
Схема Sanity: один документ — весь лендинг
Для одностраничного лендинга удобна модель «один документ типа landingPage» с вложенными объектами. Маркетолог видит одну форму, разработчик — предсказуемую структуру GROQ-запроса.
// sanity/schemas/landingPage.ts
export default {
name: 'landingPage',
title: 'Лендинг',
type: 'document',
fields: [
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'seo.title' },
},
{
name: 'seo',
title: 'SEO',
type: 'object',
fields: [
{ name: 'title', type: 'string', validation: (r) => r.max(60) },
{ name: 'description', type: 'text', rows: 3, validation: (r) => r.max(155) },
{ name: 'ogImage', type: 'image' },
],
},
{
name: 'hero',
title: 'Hero',
type: 'object',
fields: [
{ name: 'headline', type: 'string' },
{ name: 'subheadline', type: 'text' },
{ name: 'ctaLabel', type: 'string' },
{ name: 'image', type: 'image', options: { hotspot: true } },
],
},
{
name: 'faq',
title: 'FAQ',
type: 'array',
of: [{
type: 'object',
fields: [
{ name: 'question', type: 'string' },
{ name: 'answer', type: 'text' },
],
}],
},
],
};
Поле seo — единый источник для <title>, meta description и Open Graph. FAQ-массив одновременно питает UI и JSON-LD — дублирования не будет.
Для изображений hero включите hotspot и используйте @sanity/image-url на сервере: Next.js <Image> получает оптимизированный URL с нужными размерами, что напрямую влияет на LCP.
Если нужна подобная разработка — напишите в Telegram.
Fetching в App Router: RSC и generateMetadata
Next.js 15 App Router позволяет загрузить данные один раз и передать их и в metadata, и в page — без двойных запросов, если использовать паттерн с generateMetadata и общим fetcher.
// lib/sanity.fetch.ts
import { createClient } from 'next-sanity';
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: true,
});
const landingQuery = `*[_type == "landingPage" && slug.current == $slug][0]{
seo, hero, faq, _updatedAt
}`;
export async function getLanding(slug: string) {
return client.fetch(landingQuery, { slug }, {
next: { tags: [`landing:${slug}`] },
});
}
Тег landing:${slug} — основа для on-demand revalidation. Страница кэшируется, но инвалидируется точечно по webhook.
// app/(marketing)/[slug]/page.tsx
import type { Metadata } from 'next';
import { getLanding } from '@/lib/sanity.fetch';
import { urlFor } from '@/lib/sanity.image';
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const data = await getLanding(slug);
if (!data) return {};
return {
title: data.seo.title,
description: data.seo.description,
openGraph: {
title: data.seo.title,
description: data.seo.description,
images: data.seo.ogImage
? [{ url: urlFor(data.seo.ogImage).width(1200).height(630).url() }]
: [],
},
alternates: { canonical: `https://example.com/${slug}` },
};
}
export default async function LandingPage({ params }: Props) {
const { slug } = await params;
const data = await getLanding(slug);
if (!data) notFound();
return (
<>
<Hero {...data.hero} />
<FAQ items={data.faq} />
<FaqJsonLd items={data.faq} />
</>
);
}
generateMetadata и page вызывают getLanding — Next.js 15 дедуплицирует fetch в рамках одного запроса через React.cache() или встроенный request memoization. Metadata и body всегда синхронизированы: маркетолог меняет title в Sanity — меняется и <title>, и видимый H1.
Для JSON-LD используйте тот же массив faq:
export function FaqJsonLd({ items }: { items: FaqItem[] }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map((item) => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: { '@type': 'Answer', text: item.answer },
})),
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
);
}
Server Component — разметка в HTML без JavaScript, робот видит FAQPage сразу.
On-demand revalidation: webhook из Sanity
Time-based ISR (revalidate: 3600) — запасной вариант. Для лендинга лучше on-demand revalidation: контент обновляется в момент публикации, CDN отдаёт свежий HTML, TTFB остаётся минимальным.
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get('secret');
if (secret !== process.env.SANITY_REVALIDATE_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
const body = await req.json();
const slug = body?.slug?.current;
if (slug) {
revalidateTag(`landing:${slug}`);
} else {
revalidateTag('landing');
}
return NextResponse.json({ revalidated: true, slug });
}
В Sanity Studio настройте webhook на URL https://your-site.vercel.app/api/revalidate?secret=..., фильтр _type == "landingPage". При сохранении документа Sanity шлёт POST — Next.js сбрасывает кэш по тегу, следующий запрос генерирует свежий HTML на edge.
Проверка после публикации:
curl -sI https://your-site.vercel.app/landing-slug | grep -i x-vercel-cache
После revalidation первый запрос покажет MISS, последующие — HIT с актуальным контентом.
Draft Mode и Visual Editing без вреда SEO
Редактору нужно видеть черновик до публикации. Draft Mode в Next.js включает preview-версию страницы с perspective: 'previewDrafts' в Sanity client — production-кэш не затрагивается.
// app/api/draft/route.ts
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
if (secret !== process.env.SANITY_PREVIEW_SECRET) {
return new Response('Invalid token', { status: 401 });
}
(await draftMode()).enable();
redirect(`/${slug}`);
}
Sanity Visual Editing (Presentation Tool) встраивает overlay поверх сайта: клик по блоку открывает поле в Studio. Для SEO это безопасно — preview доступен только по секретному URL с включённым Draft Mode, роботы индексируют только production-кэш.
Важно: в robots.txt и <meta name="robots"> для preview-маршрутов ставьте noindex. Production-страницы остаются index, follow.
Деплой на Vercel: env, CDN и Core Web Vitals
Минимальный набор переменных окружения:
NEXT_PUBLIC_SANITY_PROJECT_ID=...
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_READ_TOKEN=... # для draft/preview
SANITY_REVALIDATE_SECRET=...
SANITY_PREVIEW_SECRET=...
Sanity CDN (useCdn: true) снижает latency fetch при сборке и ISR. Изображения — через next/image с доменом cdn.sanity.io в next.config.ts:
const nextConfig = {
images: {
remotePatterns: [{ protocol: 'https', hostname: 'cdn.sanity.io' }],
},
};
export default nextConfig;
Для hero-картинки передайте priority и точные width/height — это главный рычаг LCP. shadcn/ui-компоненты (аккордеон FAQ, кнопки CTA) остаются Client Components, но SEO-критичный текст рендерится Server Component’ом выше по дереву — INP не страдает.
Чеклист после деплоя:
- Google Search Console → «Проверка URL» — rendered HTML содержит тексты из Sanity
- Rich Results Test — FAQPage без ошибок
- PageSpeed Insights — LCP < 2,5 s, CLS < 0,1
- Опубликуйте правку в Sanity → webhook → контент обновился без redeploy
Нужна помощь? Telegram → или vic.kell@ya.ru
FAQ
Sanity или Payload CMS для лендинга?
Payload удобен, если CMS должна жить в том же monorepo на Node.js. Sanity — если нужен hosted Studio, Visual Editing из коробки и минимальная DevOps-нагрузка. Для одного SEO-лендинга Sanity быстрее в старте.
Нужен ли SSR, если контент из CMS?
Нет. ISR с on-demand revalidation даёт статический HTML из CDN — быстрее SSR и дешевле. SSR имеет смысл только для персонализации на каждый запрос (geo, A/B без edge config).
Как часто ставить time-based revalidate?
Как страховку — revalidate: 86400 (сутки). Основной механизм — webhook. Если webhook упал, страница обновится максимум через сутки.
Portable Text и SEO: не теряется ли разметка?
Portable Text рендерится в Server Component через @portabletext/react — получаете обычные <p>, <h2>, <strong>. Главное — не прятать текст за client-only табами без SSR-fallback.
Можно ли совместить Sanity с Partial Prerendering?
Да. Статические секции (hero, FAQ из CMS) попадают в prerender shell, динамические блоки (форма записи, слоты) — в Suspense-holes. CMS-контент загружается при сборке или revalidation, не на каждый запрос.
Заключение
Sanity + Next.js 15 App Router — рабочая связка для SEO-лендингов, где контент меняется часто, а Core Web Vitals остаются высокими. Схема с полем seo, единый fetcher с cache tags, generateMetadata из CMS, JSON-LD из того же FAQ-массива и on-demand revalidation через webhook закрывают типичный production-кейс.
Разработчик контролирует UI (shadcn/ui, framer-motion для декора), маркетолог — тексты и мета-теги в Studio, Google получает полный HTML с edge-кэша. Для полностью статичных лендингов без CMS по-прежнему рационален Astro 5 — но когда контент живёт отдельно от кода, Sanity в Next.js 15 остаётся одним из самых быстрых путей к production.