Sanity SEO

Sanity stores content as JSON and emits zero HTML — your frontend decides your SEO. Rendering modes, Portable Text, the SEO object, sitemaps, draft-mode noindex, JSON-LD, and plugins.

First published: Jun 27, 2026 · Last updated: Jul 19, 2026 · Advanced
demand #3 in Headless CMS#31 in Platform SEO#243 in Technical SEO#339 on the site

Sanity is a headless CMS that stores content as structured JSON in its Content Lake and produces no HTML — so your frontend framework (Next.js, Astro, Remix) decides everything about your SEO. The single biggest lever is rendering: SSG and SSR ship complete HTML and are safe; CSR is risky because LLM-crawler rendering contracts vary by provider. Portable Text is a JSON AST, not HTML, so the frontend must serialize it. Build a reusable SEO object in the content model, generate sitemaps and JSON-LD programmatically, keep drafts out of the index with an X-Robots-Tag: noindex header, and use sanity-plugin-seo (not the deprecated Yoast pane). SEO success in Sanity comes from intentional structure, not plugins.

TL;DR — Sanity is content-neutral: it stores structured JSON in the Content Lake and emits zero HTML, so the frontend’s rendering mode decides everything. SSG and SSR ship complete HTML and are safe; CSR is the risky one because LLM crawlers don’t execute JavaScript, and Google deprecated dynamic rendering as a workaround. Portable Text is a JSON AST that the frontend must serialize. Build a reusable seo object in the content model with overrides (not requirements) via coalesce(), generate sitemaps and JSON-LD programmatically from existing fields, and keep drafts out of the index with an X-Robots-Tag: noindex header on every preview response. Use sanity-plugin-seo — never the deprecated Yoast pane — but remember “SEO success in Sanity comes from intentional structure, not plugins.”

Sanity is content storage, not a publisher

A useful first principle: Sanity touches your content, never your rendering. The Content Lake stores everything as structured JSON, queried with GROQ (Sanity’s query language) or REST. Sanity Studio — the editing UI — is a React single-page app that usually lives on its own .sanity.studio subdomain and isn’t something you index. Nothing in this stack produces HTML.

Evidence for this claim Sanity Content Lake is queried using APIs and GROQ; it is distinct from a site's frontend rendering layer. Scope: Sanity-hosted content data and query APIs. Confidence: high · Verified: Sanity: Content Lake

So the entire SEO surface lives in your frontend framework. As Webstacks put it, “Sanity handles content storage and editing, while your frontend framework handles SEO output.” That’s not a limitation — it’s the whole headless model. Sanity’s own team frames it well: “When using Sanity as a CMS, you’re doing headless SEO. Although this approach requires thoughtful technical implementation, it can liberate your content to help you reach more users across all channels.” The work is real, but it’s the same work as any headless build (see SEO for a Headless CMS).

Rendering mode is the foundation

Because the frontend builds the HTML, the rendering decision is the single most consequential SEO choice on a Sanity site. Four modes:

SSG — Static Site Generation. Content is fetched from Sanity at build time and baked into static HTML served from a CDN. Best-case SEO: fully-rendered HTML on first request, fast TTFB. Ideal for blog posts, marketing pages, and product/landing pages. The tradeoff is freshness — changed content needs a rebuild (ISR solves this).

SSR — Server-Side Rendering. Content is fetched per request and the full HTML is rendered on the server. Excellent SEO, always fresh. Use it for frequently updated content. Tradeoff is infrastructure cost and slightly higher TTFB than static files.

ISR — Incremental Static Regeneration (Vercel/Next.js). Static pages regenerate in the background, ideally triggered by a Sanity webhook on publish so the page rebuilds the moment an editor hits publish. This is the best of SSG’s speed and SSR’s freshness for content that changes on the order of hours or days.

CSR — Client-Side Rendering. An HTML shell ships, then JavaScript fetches from Sanity and builds the DOM in the browser. This is the worst SEO option: Googlebot has to queue the page for a later render wave, timing is unpredictable, and AI crawlers and many other bots see only the shell. CSR is acceptable only behind authentication for content you don’t want indexed anyway.

And don’t reach for the old shortcut: Google has deprecated dynamic rendering (serving prerendered HTML to bots, CSR to users). Officially, “dynamic rendering was a workaround and not a long-term solution,” and Google now recommends “server-side rendering, static rendering, or hydration.” For a Sanity site, that means SSG or SSR — not a bot-detection hack. (Full rendering treatment in JavaScript SEO.)

Evidence for this claim Google describes dynamic rendering as a workaround rather than a recommended long-term solution and recommends server-side rendering, static rendering, or hydration. Scope: Google Search guidance for JavaScript sites. Confidence: high · Verified: Google: Dynamic rendering

Portable Text — your body copy is a JSON AST

This is the most Sanity-specific thing on the page. Sanity stores rich text as Portable Text: a JSON abstract syntax tree, not HTML. Body copy in the Content Lake looks like an array of typed blocks, not <p> tags. Your frontend has to serialize it into HTML before any crawler can read it:

  • @portabletext/react — for React/Next.js.
  • @portabletext/to-html — framework-agnostic (Astro, Remix, anything).
  • The old @sanity/block-content-to-react is deprecated — don’t use it.

If you render server-side or at build time, serialization happens before the page is sent and crawlers get full HTML. If you render client-side, the serialization runs in the browser. Because LLM-crawler rendering contracts vary by provider, CSR Portable Text creates a coverage risk that raw HTML avoids. There’s also a handy GROQ helper: pt::text(body) extracts all the plain text from a Portable Text field as a string, which is perfect for feeding JSON-LD descriptions without a separate editor field.

Content modeling for SEO — the reusable SEO object

The cleanest pattern, and the one Sanity’s own course teaches, is a single reusable seo object added to every document type — not SEO fields copy-pasted per type. The key design principle from Sanity Learn: “For the benefit of content authors, fields relevant to SEO should not always be required. Instead, they should be used to override some existing content, when provided.”

In practice that means SEO fields are overrides with fallbacks, implemented with GROQ’s coalesce():

"title": coalesce(seo.title, title, ""),
"description": coalesce(seo.description, excerpt, "")

A minimum viable seo object: metaTitle (≤65 chars), metaDescription (≤155 chars), canonicalUrl, openGraphImage (1200×630), and a noIndex boolean. Add Sanity validation rules to enforce those character limits before publish — so editors catch problems the platform would otherwise let through. Use a slug type with a custom generation function, and use references for internal links so URL changes propagate automatically. Add a hideFromSearch boolean and filter it out of both the sitemap GROQ query and the page’s robots metadata.

Metadata, sitemaps, and canonicals — all built in the frontend

Metadata. In Next.js App Router, use the exported generateMetadata() function (not inline <head> tags) so metadata doesn’t duplicate across nested layouts. Build one server-side helper that takes a GROQ result and returns the framework’s metadata object — implement it once, use it everywhere. Open Graph images can be generated dynamically with Next.js Edge OG generation, pulling content straight from Sanity.

Sitemaps. There’s no auto-sitemap. You build it from a GROQ query → URL array → XML (sitemap.ts in Next.js, @astrojs/sitemap in Astro). Always filter drafts and hidden pages:

*[_type in ["page", "post"] && defined(slug.current) && hideFromSearch != true]

Use _updatedAt (not _createdAt or a manual date) for lastModified, keep individual sitemap files under 50,000 URLs with an index for larger sites, and trigger regeneration via a Sanity webhook on publish/unpublish so it never goes stale.

Canonicals. Set canonical URLs from the content model, default to a self-referencing canonical, and build absolute URLs from a single SITE_URL so the canonical never drifts. (Mechanics in Canonicalization.)

Structured data — generate it, don’t author it

The right pattern is to generate JSON-LD programmatically from existing content fields at render time — never build a separate JSON-LD editor surface in Studio, which just creates drift between content and markup. Roboto Studio’s rule is blunt: “Derive JSON-LD from existing fields, at render time, in code.” Sanity agrees — “JSON-LD is a powerful way to provide structured data to search engines — fortunately structured data is what Sanity does best.”

Use pt::text(body) to pull plain text for schema descriptions, the TypeScript package schema-dts for type safety, and render it with <script type="application/ld+json">. Note that “the JSON-LD markup can be rendered anywhere in the page — it doesn’t need to be stored inside the <head>.” Priority types for content sites: Article/BlogPosting, BreadcrumbList, FAQPage, Organization, WebSite.

Keeping drafts out of Google — the real-incident pitfall

Sanity’s perspective system controls draft visibility: published returns only production content; drafts returns draft documents for preview. Always use the published perspective in production API calls. But that alone isn’t enough, because draft URLs leak. As one developer recounted, the danger is “a client panicking because an unpublished landing page showed up in Google Search Console. If a content editor shares that URL in Slack, and someone clicks it from a browser that’s then crawled (it happens), the draft content can end up indexed.”

The reliable defense is a multi-layer one:

  1. Use the published perspective in production; never expose an unprotected preview endpoint.
  2. Add an X-Robots-Tag: noindex HTTP header to every draft-mode/preview response — even if a draft URL leaks and gets crawled, Googlebot respects the header and won’t index it. (Don’t rely on robots.txt for this; a header is more reliable than a disallow for preview pages.)
  3. Validate the preview secret server-side; never reflect it in the redirect URL, and scope the preview-enable endpoint to your Studio’s origin.

Robots, redirects, image SEO, and hreflang

robots.txt is a static frontend file (app/robots.ts in Next.js). Block Studio paths only if they’re on the same domain (Disallow: /studio/); don’t try to block preview pages with it.

Redirects are a genuine workflow win: store them as Sanity documents (from, to, statusCode) so the content team can manage redirects without a developer deploy, then implement them in Next.js middleware.ts or a CDN/Worker layer (never in Sanity itself). Add validation rules to prevent loops and invalid paths.

Image SEO: use Sanity’s image CDN with ?auto=format for automatic WebP/AVIF, manage alt text at the asset level with document-level overrides, use vanity filenames instead of hashed URLs, and set hotspot/crop so automatic crops keep the subject — which matters most for OG images.

Hreflang: model locale variants as separate Sanity documents referencing the canonical version, generate hreflang from a GROQ query returning all locale slugs, and give every locale page a self-referencing canonical — never canonical a non-English page to the English one.

Plugins — helpful, not a substitute

  • sanity-plugin-seo — the primary recommendation (22k+ downloads). Live SEO score, meta/OG preview, social cards, robots control, hreflang, readability. The AI tier adds keyword suggestions and meta generation.
  • sanity-plugin-seofields — an alternative: 39 schema.org types, an SEO Health Dashboard, and Next.js helper functions (buildSeoMeta()).
  • sanity-plugin-seo-paneDEPRECATED. This is the old Yoast integration, removed due to an outdated dependency. It still shows up in old tutorials. Don’t use it.

The honest framing, from Yanatiev: “SEO success in Sanity comes from intentional structure, not plugins.” Plugins surface fields and give editorial feedback — they don’t replace proper content modeling.

AI search and AEO

The 2026 wrinkle: LLM training and retrieval crawlers (Anthropic, OpenAI, Common Crawl) typically don’t execute JavaScript today, so CSR Portable Text is largely invisible to them regardless of whether Googlebot can render it — but this is provider-specific and not a guaranteed contract. Google’s Gemini pipeline is the notable exception, since it reuses Googlebot’s own rendering infrastructure, and other providers’ behavior can change. Treat “no rendering” as the safe assumption to build for, not a permanent law. Beyond rendering, two Sanity-friendly tactics: serve clean markdown via content negotiation when Accept: text/markdown is present (Sanity’s @portabletext/markdown package converts Portable Text with no extra authoring), and monitor Bing specifically since Bing’s index feeds ChatGPT’s browse responses. On llms.txt, Sanity’s own Knut Melvær is skeptical — “Probably don’t do llms.txt” for large sites, because the all-or-nothing scope and file-size limits make it impractical; per-page content negotiation is more granular. Roboto Studio’s summary is the right mindset: “AEO and SEO live on the same plumbing, so we treat them as one job.” (More in AI Search.)

The myths worth killing

  • “Sanity does SEO automatically like WordPress + Yoast.” No. Zero HTML, zero automatic anything.
  • “Any rendering mode is fine if the content’s in Sanity.” No. CSR breaks indexing and AI visibility; dynamic rendering is deprecated.
  • “Drafts can’t be indexed.” They can, if a preview URL leaks. Use a noindex header.
  • “Portable Text is HTML.” It’s a JSON AST that the frontend must serialize.
  • “You need a JSON-LD editor in Studio.” No — generate it from existing fields.

Add an expert note

Pin an expert quote

New person? Create their unclaimed profile at /admin/experts/ → Pin an expert quote first.