Sanity CMS + Next.js 15: SEO Landing with Revalidation — KEL IT
Websites 6 min read

Sanity CMS + Next.js 15: SEO Landing Pages with On-Demand Revalidation

Marketing teams need to update hero copy, FAQ entries, and pricing without waiting for a deploy. Engineers need Google to receive fully rendered HTML on the first request — with LCP under 2.5 seconds. WordPress solves the first problem but often loses on Core Web Vitals. Hard-coded Next.js content is fast, but every text change means a PR and a build.

Sanity as a headless CMS addresses both sides. Content lives in Studio; Next.js 15 App Router renders it through Server Components so HTML, metadata, and JSON-LD share a single source of truth. On-demand revalidation via webhook refreshes pages within seconds of publish — no full rebuild, no SSR on every hit.

This guide covers a production landing setup: Sanity schema, RSC fetching, generateMetadata, draft preview, Vercel webhooks, and SEO verification.

Why Headless CMS for SEO Landings in 2026

Service landing pages evolve weekly — headline A/B tests, seasonal offers, new testimonials. Content baked into .tsx files creates a bottleneck: marketing depends on engineering for every copy tweak.

Headless CMS separates content from presentation. Sanity stores structured documents; Next.js renders them to HTML at the edge. For SEO, the critical rule is server-side rendering — never fetch landing copy in useEffect. Crawlers and Core Web Vitals both suffer from client-only content.

Sanity stands out with GROQ queries, a built-in asset CDN, and a generous free tier. Alternatives like Contentful, Payload, and Strapi follow the same pattern; choose based on team workflow, not SEO fundamentals.

Sanity Schema: One Document, Full Landing

For a single-page landing, model everything as one landingPage document with nested objects. Marketers get one form; developers get a predictable GROQ query.

export default {
  name: 'landingPage',
  type: 'document',
  fields: [
    { name: 'slug', type: 'slug', options: { source: 'seo.title' } },
    {
      name: 'seo',
      type: 'object',
      fields: [
        { name: 'title', type: 'string', validation: (r) => r.max(60) },
        { name: 'description', type: 'text', validation: (r) => r.max(155) },
        { name: 'ogImage', type: 'image' },
      ],
    },
    {
      name: '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',
      type: 'array',
      of: [{
        type: 'object',
        fields: [
          { name: 'question', type: 'string' },
          { name: 'answer', type: 'text' },
        ],
      }],
    },
  ],
};

The seo object feeds <title>, meta description, and Open Graph from one place. The FAQ array powers both the UI accordion and JSON-LD — no duplication, no structured-data spam risk.

Need similar development? Message on Telegram.

App Router Fetching: RSC and generateMetadata

Load landing data once and share it between metadata and page. Next.js 15 deduplicates fetch calls within a request.

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,
});

export async function getLanding(slug: string) {
  return client.fetch(
    `*[_type == "landingPage" && slug.current == $slug][0]{ seo, hero, faq }`,
    { slug },
    { next: { tags: [`landing:${slug}`] } },
  );
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const data = await getLanding(slug);
  return {
    title: data.seo.title,
    description: data.seo.description,
    openGraph: {
      title: data.seo.title,
      description: data.seo.description,
    },
    alternates: { canonical: `https://example.com/${slug}` },
  };
}

Render FAQ JSON-LD in a Server Component using the same array — crawlers see FAQPage markup without waiting for JavaScript.

On-Demand Revalidation via Webhook

Time-based ISR (revalidate: 3600) is a fallback. For landings, on-demand revalidation is better: content updates the moment someone hits Publish in Studio.

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';

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 { slug } = await req.json();
  revalidateTag(`landing:${slug.current}`);

  return NextResponse.json({ revalidated: true });
}

Configure a Sanity webhook pointing to /api/revalidate?secret=... filtered to _type == "landingPage". The next request regenerates fresh HTML; subsequent hits serve from CDN cache.

Draft Mode and Visual Editing

Editors need preview before publish. Draft Mode switches the Sanity client to perspective: 'previewDrafts' without touching production cache.

export async function GET(req: Request) {
  const secret = searchParams.get('secret');
  if (secret !== process.env.SANITY_PREVIEW_SECRET) {
    return new Response('Invalid token', { status: 401 });
  }
  (await draftMode()).enable();
  redirect(`/${slug}`);
}

Sanity Visual Editing overlays clickable regions on the live site. Keep preview routes noindex — only production cache should be crawlable.

Vercel Deploy and Core Web Vitals

Required env vars: NEXT_PUBLIC_SANITY_PROJECT_ID, NEXT_PUBLIC_SANITY_DATASET, SANITY_REVALIDATE_SECRET, SANITY_PREVIEW_SECRET.

Add cdn.sanity.io to images.remotePatterns and pass priority to the hero <Image> — the main LCP lever. shadcn/ui components (FAQ accordion, CTA buttons) can stay client-side; SEO-critical text renders in Server Components above them, keeping INP healthy.

Post-deploy checklist:

  1. Search Console URL Inspection — rendered HTML matches Sanity content
  2. Rich Results Test — FAQPage validates
  3. PageSpeed Insights — LCP < 2.5s, CLS < 0.1
  4. Publish in Sanity — webhook fires, content updates without redeploy

Need help? Telegram → or vic.kell@ya.ru

FAQ

Sanity or Payload for a landing page?

Payload fits monorepos where CMS runs on your Node server. Sanity fits when you want hosted Studio, Visual Editing, and minimal ops. For a single SEO landing, Sanity is usually faster to ship.

Do I need SSR with CMS content?

No. ISR + on-demand revalidation serves static HTML from CDN — faster and cheaper than SSR. Reserve SSR for per-request personalization.

How often should I set time-based revalidate?

Use it as a safety net — e.g. revalidate: 86400. Webhooks should be the primary update path.

Does Portable Text hurt SEO?

Not when rendered in a Server Component via @portabletext/react. You get normal <p> and <h2> tags. Avoid hiding text behind client-only tabs without an SSR fallback.

Conclusion

Sanity + Next.js 15 App Router is a proven stack for SEO landings where content changes often but performance stays high. A schema with dedicated seo fields, cache tags, generateMetadata from CMS data, shared FAQ JSON-LD, and webhook revalidation covers most production needs.

Engineers own UI and performance; marketers own copy in Studio; Google gets full HTML from the edge cache. For fully static landings with no CMS, Astro 5 remains a strong choice — but when content lives outside the codebase, Sanity on Next.js 15 is one of the fastest paths to production.

KEL IT

Need a custom solution?

I build these types of projects professionally. Telegram bots, Mini Apps, websites, mobile and desktop applications. Tell me about your project and I'll get back to you with a plan.