Sanity CMS в Next.js 15: SEO-лендинг с revalidation — KEL IT
Сайты 9 мин чтения

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 не страдает.

Чеклист после деплоя:

  1. Google Search Console → «Проверка URL» — rendered HTML содержит тексты из Sanity
  2. Rich Results Test — FAQPage без ошибок
  3. PageSpeed Insights — LCP < 2,5 s, CLS < 0,1
  4. Опубликуйте правку в 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.

KEL IT

Нужна разработка под ключ?

Я занимаюсь такими проектами профессионально. Telegram-боты, Mini Apps, сайты, мобильные и десктопные приложения. Расскажите о задаче — разберём и предложим решение.