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:
- Search Console URL Inspection — rendered HTML matches Sanity content
- Rich Results Test — FAQPage validates
- PageSpeed Insights — LCP < 2.5s, CLS < 0.1
- 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.