{"id":24865,"date":"2026-10-20T10:12:54","date_gmt":"2026-10-20T08:12:54","guid":{"rendered":"https:\/\/pagepro.co\/blog\/?p=24865"},"modified":"2026-11-09T19:39:02","modified_gmt":"2026-11-09T18:39:02","slug":"seo-best-practices-for-sanity-cms","status":"publish","type":"post","link":"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/","title":{"rendered":"SEO Best Practices for Sanity CMS"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\" id=\"how-seo-works-in-headless-sanity-cms\">How SEO Works in Headless Sanity CMS<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In a headless setup, SEO is built by your schemas and your frontend, not by the CMS. Sanity keeps content in its <strong>Content Lake<\/strong> and outputs no HTML of its own, so it won&#8217;t rescue or ruin your search visibility on its own. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For a CTO, this means SEO stops being a plugin setting and becomes part of your architecture. Schema design, rendering strategy and cache invalidation decide whether pages get indexed, and the risk peaks during a migration. The checklist below is what your team should have in place before launch, and what to ask a vendor to prove. The headless pipeline rests on two pillars:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><a href=\"https:\/\/pagepro.co\/blog\/what-is-content-modeling\/\">Content modeling<\/a> in Sanity Studio:<\/strong> Editors enter page-level titles, Open Graph images, search directives, and schema fields into schema objects. Sanity doesn&#8217;t generate HTML tags out of the box, so your schemas have to define every attribute search crawlers need.<\/li>\n\n\n\n<li><strong>Frontend rendering and delivery:<\/strong> Frontend frameworks (such as Next.js, Remix, or Nuxt) query Sanity through <a href=\"https:\/\/www.sanity.io\/docs\/content-lake\/groq-introduction\">GROQ<\/a> or GraphQL. The application code pulls those properties out to assemble crawlable server-side HTML, accurate JSON-LD blocks, hreflang links, and real-time sitemaps.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Teams moving to Sanity often expect that typing a title will fill in the metadata tags in the HTML head. It won&#8217;t: if you don&#8217;t query those fields in your page layouts, search bots will see empty titles, missing meta descriptions, and default host canonicals. Treat SEO as an engineering requirement from day one. For the Next.js side of the same work, see the <a href=\"https:\/\/pagepro.co\/blog\/nextjs-seo\/\">Next.js SEO guide<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"seo-best-practices-for-sanity-cms-content-models\">SEO Best Practices for Sanity CMS Content Models<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A Sanity content model for SEO gives every document that resolves to a public URL the same set of search fields: meta title, description, canonical, robots flags and social images. Define them once as a reusable object, validate their length in Studio, and let the frontend fill gaps from editorial fields, so no public page ships without tags.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"building-a-reusable-seo-object\"><span class=\"underline-accent\">Building a Reusable SEO Object<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Rather than declaring individual meta fields again and again across document types, define one reusable SEO object type. You write it once and embed it in every indexable document. It should cover a few core properties:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Meta Title and Description:<\/strong> Dedicated string and text fields for search result snippets, kept separate from internal editorial headlines.<\/li>\n\n\n\n<li><strong>Canonical URL Override:<\/strong> An optional field that lets editors set self-referential or cross-domain URLs when republishing syndicated material.<\/li>\n\n\n\n<li><strong>Robots Directives:<\/strong> A <code>noIndex<\/code> boolean and related flags that tell frontend renderers which indexing meta tags to output.<\/li>\n\n\n\n<li><strong>Social Share Previews:<\/strong> Open Graph and Twitter card fields, including custom social titles, descriptions, and 1200&#215;630 pixel promotional images.<\/li>\n\n\n\n<li><strong>Structured Data Inputs:<\/strong> Fields for selecting schema types (such as Article, FAQ, or Product) and mapping entities for rich results.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A minimal version of the object, defined with Sanity&#8217;s <a href=\"https:\/\/www.sanity.io\/docs\/studio\/object-type\">object type<\/a>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import { defineField, defineType } from 'sanity';\n\nexport const seo = defineType({\n name: 'seo',\n title: 'SEO',\n type: 'object',\n fields: &#91;\n defineField({ name: 'metaTitle', type: 'string', validation: (Rule) =&gt; Rule.max(60).warning('Keep titles under 60 characters.') }),\n defineField({ name: 'metaDescription', type: 'text', rows: 3, validation: (Rule) =&gt; Rule.min(120).max(160).warning('Aim for 120 to 160 characters.') }),\n defineField({ name: 'canonicalUrl', type: 'url' }),\n defineField({ name: 'noIndex', type: 'boolean', initialValue: false }),\n defineField({ name: 'hideFromLists', type: 'boolean', initialValue: false }),\n defineField({ name: 'ogImage', type: 'image' }),\n ],\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Add it to a document with <code>defineField({ name: 'seo', type: 'seo' })<\/code>. Embedding this object in public document schemas gives technical teams a predictable data contract. Add a site-wide settings singleton next to it, where editors manage default metadata, site verification tokens and fallback images without touching code.<\/p>\n\n\n\n<div class=\"wp-block-code-mind-cta c-cta-block\" style=\"background-color:#00141F;color:#FFFFFF\"><div class=\"c-cta-block__content\"><p class=\"c-cta-block__title\">Sanity doesn\u2019t fix SEO automatically. Your architecture does.<\/p><div class=\"c-cta-block__action\"><a href=\"https:\/\/pagepro.co\/contact\" class=\"c-cta-block__button ga-cta ga-cta-consultation theme-bg-3\">Optimize Next.js + Sanity<\/a><\/div><\/div><\/div>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"one-search-intent-per-document\"><span class=\"underline-accent\">One Search Intent per Document<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Each Sanity document should serve one user intent instead of piling unrelated topics onto a single URL. Sanity schemas are unconstrained, which makes it tempting to build sprawling page builders that bundle several topics onto one page; that dilutes topical relevance and leads to keyword cannibalization and thin or duplicate pages. Reserve top-level documents for self-contained topics and use reference fields to link supporting subtopics.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"title-and-description-length-limits\"><span class=\"underline-accent\">Title and Description Length Limits<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Search engines measure snippet space in pixels, not characters, but character counts are still the practical standard for authors in Sanity Studio. For reliable SERP rendering, keep title tags between 50\u201360 characters and meta descriptions between 120\u2013160 characters. Put your core keyword up front. Google writes its own <a href=\"https:\/\/developers.google.com\/search\/docs\/appearance\/title-link\">title links<\/a> and <a href=\"https:\/\/developers.google.com\/search\/docs\/appearance\/snippet\">snippets<\/a> when yours don&#8217;t fit the query, so these limits improve the odds rather than fix the display.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Inside Sanity Studio, you can automate quality control by adding <a href=\"https:\/\/www.sanity.io\/docs\/studio\/validation\">validation rules<\/a> to the reusable SEO schema fields:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Meta Title Validation:<\/strong> Add <code>validation: (Rule) =&gt; Rule.max(60).warning('Titles longer than 60 characters risk truncation in search results.')<\/code> to the title string. Editors get instant feedback without being blocked from publishing.<\/li>\n\n\n\n<li><strong>Meta Description Validation:<\/strong> Set a rule on the description text area, such as <code>Rule.min(120).max(160).warning('Descriptions between 120 and 160 characters maximize SERP visibility.')<\/code>.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"metadata-overrides-and-fallback-order\"><span class=\"underline-accent\">Metadata Overrides and Fallback Order<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Content creators shouldn&#8217;t have to paste the same headline, summary, and teaser image into several fields just to satisfy search bots and social platforms. A better Sanity schema treats explicit SEO and social fields as optional overrides and leaves the heavy lifting to a predictable fallback chain in your frontend:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Meta Title Fallback:<\/strong> The renderer checks the dedicated title override first, then the document&#8217;s main title or editorial headline, and finally the global site title from global settings.<\/li>\n\n\n\n<li><strong>Meta Description Fallback:<\/strong> It checks the description override, then the document&#8217;s intro summary or post excerpt, and finally a site-wide fallback description.<\/li>\n\n\n\n<li><strong>Social Share (Open Graph) Fallback:<\/strong> Open Graph tags (og:title, og:description, og:image) look for specific Open Graph overrides first, then the dedicated SEO fields, then the document&#8217;s headline, summary, and main hero image, and only then a generic brand fallback asset.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The final resolved metadata has to be built and injected into the document head through <a href=\"https:\/\/pagepro.co\/blog\/ssrvscsr\/\">server-side rendering<\/a> (SSR) or static site generation (SSG). In the Next.js App Router, that is the job of <a href=\"https:\/\/nextjs.org\/docs\/app\/api-reference\/functions\/generate-metadata\">generateMetadata<\/a>: it fetches the document&#8217;s SEO fields from Sanity, applies the fallback chain, and returns the title, description, canonical (<code>alternates.canonical<\/code>), robots and Open Graph values, which Next.js renders into the initial HTML.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"how-to-structure-content-for-search-and-ai\">How to Structure Content for Search and AI<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Search algorithms and generative AI engines split documents into discrete chunks of knowledge, so a Sanity content model that keeps semantic boundaries intact is easier for them to read. In practice that means a strict heading hierarchy in Portable Text, typed fields for FAQs and summaries, and the same GROQ projection behind every format you serve.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"establish-a-strict-heading-hierarchy\"><span class=\"underline-accent\">Establish a Strict Heading Hierarchy<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Every template must output one H1 for the core topic, then H2 subtopics and nested H3 and H4 details. If you skip levels, like jumping from an H1 straight to an H3 inside a modular component, you break the logical chain that parsing bots and screen readers depend on. Configure Portable Text block definitions to allow only the heading levels suited to body text: H2, H3, and H4. For modular landing pages, add an explicit <code>headingLevel<\/code> select field to the module schemas, so publishers choose whether a module header renders as an H2 or H3 depending on where the module sits in the page outline.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"model-modular-entities-faqs-and-summaries\"><span class=\"underline-accent\">Model Modular Entities: FAQs and Summaries<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Give answers their own typed fields instead of burying them in running paragraphs:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Structured FAQ Objects:<\/strong> Create a dedicated FAQ object schema with separate question (string) and answer (scoped Portable Text) fields. Grouping these items in a parent array lets search indexers pull definitive answers and makes it easy to generate accurate FAQPage JSON-LD. Google limited FAQ rich results to government and health sites in 2023 and <a href=\"https:\/\/developers.google.com\/search\/updates\">stopped showing them in Search<\/a> in May 2026, so treat FAQ markup as a signal for AI and search parsers, not a snippet.<\/li>\n\n\n\n<li><strong>Executive Summaries and TL;DR Blocks:<\/strong> Give editorial document types a dedicated summary or <code>keyTakeaways<\/code> array field, so key takeaways sit at the top of the document for human visitors and automated extractors.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"markdown-endpoints-and-llms-txt-for-ai-agents\"><span class=\"underline-accent\">Markdown Endpoints and llms.txt for AI Agents<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">AI user agents prefer lightweight, token-efficient formats. When a request carries an <code>Accept: text\/markdown<\/code> header, the application can return a Markdown version at the same canonical URL. Both the web page and the Markdown response should query Sanity with the same GROQ projection, so what human users see and what retrieval systems extract stay identical, which removes cloaking risk and avoids duplicate URLs. Send a <code>Vary: Accept<\/code> header with both responses, so your CDN (Vercel&#8217;s Edge Network included) doesn&#8217;t serve the cached Markdown version to browsers or the HTML version to agents.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Files like <a href=\"https:\/\/llmstxt.org\/\">llms.txt<\/a> act as navigation aids, not proven ranking levers. If you publish one, drive it from a GROQ query, and let editors toggle a boolean field like <code>featuredForLLM<\/code> in Studio documents to mark which core guides or glossaries belong in it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"topic-clusters-and-internal-linking-in-sanity\">Topic Clusters and Internal Linking in Sanity<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In Sanity, you can model topic clusters right in your schema, so link equity flows where you want it and crawlers follow contextual paths without hitting broken links or orphaned URLs. A topic reference ties each article to one pillar, and link annotations that point to document IDs keep internal links correct when slugs change.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"model-pillar-and-cluster-relationships\"><span class=\"underline-accent\">Model Pillar and Cluster Relationships<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A topic cluster pairs one broad core page with 8\u201312 cluster pages that dig into narrower subtopics. Define a dedicated taxonomy document type, such as topic, or add a single-select <code>primaryTopic<\/code> reference field to all publishing document types, so each article belongs to exactly one parent subject. Alongside it, add an array of <code>relatedPages<\/code> references to connect sibling pages within a cluster.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"automate-internal-links-and-prevent-link-rot\"><span class=\"underline-accent\">Automate Internal Links and Prevent Link Rot<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Hardcoded internal URLs inside rich text break the moment an author tweaks a slug, creating redirect chains or 404 crawl errors. Sanity Studio avoids this with reference annotations in Portable Text:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Reference Annotations in Portable Text:<\/strong> Set up custom link marks that point to Sanity document IDs instead of static href paths. When an editor changes a destination page&#8217;s slug, every internal link to that document resolves to the new URL with no manual work.<\/li>\n\n\n\n<li><strong>Bidirectional Related Content Modules:<\/strong> Query reverse references in GROQ (such as <code>*[_type == \"post\" &amp;&amp; references(^._id)]<\/code>). Showing reciprocal links on child pages strengthens the crawl path back to the pillar and removes orphan pages.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"canonical-urls-slugs-and-redirects-in-sanity\">Canonical URLs, Slugs and Redirects in Sanity<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Standardizing slug generation, canonical tags, and redirects in Sanity tells search engines which page variant is the canonical one, even when query parameters, trailing slashes, or legacy paths serve the same content. Generate slugs from titles, compute a self-referential canonical by default, and keep redirects as Studio documents that marketing can edit without a deployment.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"programmatic-slug-generation-and-nested-architectures\"><span class=\"underline-accent\">Programmatic Slug Generation and Nested Architectures<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Sanity Studio generates slugs through the core <a href=\"https:\/\/www.sanity.io\/docs\/studio\/slug-type\">slug schema type<\/a>, which builds URL segments from source fields such as titles. A custom slugify function can normalize diacritics and force lowercase. For nested page hierarchies, an asynchronous function can query parent document references through GROQ to build full paths like \/solutions\/enterprise\/analytics.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"canonical-url-configuration\"><span class=\"underline-accent\">Canonical URL Configuration<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Self-referential canonical tags are the main defense against index fragmentation caused by tracking parameters and different entry routes. Keep the manual override in the SEO object, but build fallback logic on the frontend that calculates the default canonical from your primary production domain and the resolved document slug. That removes HTTP and HTTPS inconsistencies and enforces one trailing-slash convention across all rendered pages. Google&#8217;s guide to <a href=\"https:\/\/developers.google.com\/search\/docs\/crawling-indexing\/consolidate-duplicate-urls\">consolidating duplicate URLs<\/a> covers the signals in detail.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"managing-redirects-through-studio-schemas\"><span class=\"underline-accent\">Managing Redirects Through Studio Schemas<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Changing a published slug breaks existing external backlinks unless the old address redirects permanently to the new one. To give marketing teams control without a code deployment, create a dedicated redirect schema type in Sanity Studio:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Custom Document Schema:<\/strong> Define fields for <code>sourcePath<\/code>, <code>destination<\/code> (supporting both internal document references and external URL strings), <code>statusCode<\/code> (301 for permanent moves, 302 for temporary campaigns), and an optional regex toggle. With internal references, a redirect stays accurate even if the target document&#8217;s URL changes again.<\/li>\n\n\n\n<li><strong>Build-Time and Edge Delivery:<\/strong> Fetch the active redirect table with GROQ at build time, or cache it in an edge layer (such as <a href=\"https:\/\/nextjs.org\/docs\/app\/api-reference\/file-conventions\/proxy\">Next.js Proxy<\/a>, formerly Middleware, or Cloudflare Workers), so the redirect runs before the route renders.<\/li>\n\n\n\n<li><strong>Wildcard and Regex Support:<\/strong> Support capture groups to migrate whole legacy folders, turning patterns like \/blog\/archive\/:slug into \/resources\/:slug without writing individual entries for hundreds of older posts.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">If you are moving an existing site to Sanity, the <a href=\"https:\/\/pagepro.co\/blog\/headless-cms-seo-migration-guide\/\">headless CMS SEO migration guide<\/a> covers the redirect map and launch checks.<\/p>\n\n\n\n<div class=\"wp-block-code-mind-newsletter c-newsletter-block\"><p class=\"c-newsletter-block__title c-newsletter__header\">Sign up for newsletter!<\/p><p class=\"c-newsletter-block__desc c-newsletter__header\">Every month, we send a selection of news from the tech industry to your email inbox.<\/p><form method=\"post\" class=\"c-newsletter-block__form js-newsletter-form c-newsletter__action\" name=\"newsletter-block-form\"><input name=\"newsletter-email\" id=\"newsletter-email\" type=\"text\" class=\"c-newsletter-block__input js-newsletter-input\" placeholder=\"Email\"\/><input name=\"newsletter-campaign\" id=\"newsletter-campaign\" type=\"hidden\"\/><div class=\"c-newsletter-block__group\"><input name=\"consent\" type=\"checkbox\" class=\"js-newsletter-consent\"\/><label for=\"consent\">I accept the <a href=\"https:\/\/pagepro.co\/privacy-policy\">Privacy Policy<\/a> and agree to process my personal data by Pagepro for marketing purposes.<\/label><\/div><input type=\"submit\" class=\"c-newsletter-block__button js-newsletter-sub ga-newsletter-form-content\" value=\"Sign up\"\/><p class=\"theme-size-1 js-message-valid is-hidden is-invalid\"><\/p><\/form><\/div>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"sanity-sitemaps-robots-and-noindex-rules\">Sanity Sitemaps, Robots and Noindex Rules<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In a decoupled Sanity architecture, sitemaps, robots rules and noindex directives are endpoints built from your dataset, so they stay accurate only when they share one source of truth with the pages. Build the sitemap from the same GROQ queries as your routes, filter out drafts and noindex documents, and block indexing on every staging host.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"programmatic-xml-sitemaps-with-unified-groq-queries\"><span class=\"underline-accent\">Programmatic XML Sitemaps with Unified GROQ Queries<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">An accurate XML sitemap lists only canonical URLs that return a clean 200 HTTP status. If the sitemap uses the same GROQ queries that drive your production routing, the indexable inventory stays in sync. In Next.js, an <a href=\"https:\/\/nextjs.org\/docs\/app\/api-reference\/file-conventions\/metadata\/sitemap\">app\/sitemap.ts<\/a> file can run those queries and return the URL list. Fill <code>lastmod<\/code> from a revision date that editors set only after real changes (see the freshness section), not from <code>_updatedAt<\/code>, which every minor save changes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Indexable Document Filtering:<\/strong> Exclude drafts and noindex documents right in your projection, such as <code>*[_type in [\"page\", \"post\"] &amp;&amp; !(_id in path(\"drafts.**\")) &amp;&amp; seo.noIndex != true]<\/code>.<\/li>\n\n\n\n<li><strong>Sitemap Partitioning:<\/strong> Split large platforms by content type (such as \/sitemap-articles.xml and \/sitemap-products.xml) so you can track them separately in Google Search Console. Keep each file under <a href=\"https:\/\/developers.google.com\/search\/docs\/crawling-indexing\/sitemaps\/build-sitemap\">50 MB and 50,000 URLs<\/a>, grouped under a sitemap index.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"separate-noindex-from-hidefromlists\"><span class=\"underline-accent\">Separate noIndex from hideFromLists<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Editorial teams often mix up internal site visibility with external indexation directives, so model them as two fields. <code>seo.noIndex<\/code> adds a <code>meta name=\"robots\" content=\"noindex, follow\"<\/code> directive to the rendered HTML head, and the sitemap query above filters the same documents out, so you never send contradictory signals. <code>seo.hideFromLists<\/code> controls only frontend presentation: GROQ queries filter the document out of overview grids, category feeds, recent-post blocks and site search. Google&#8217;s <a href=\"https:\/\/developers.google.com\/search\/docs\/crawling-indexing\/block-indexing\">noindex documentation<\/a> explains why a noindex page must stay crawlable for the directive to work.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"dynamic-robots-txt-and-environment-indexing-guards\"><span class=\"underline-accent\">Dynamic robots.txt and Environment Indexing Guards<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Generate robots.txt dynamically from a site settings document in Sanity. In Next.js, an <a href=\"https:\/\/nextjs.org\/docs\/app\/api-reference\/file-conventions\/metadata\/robots\">app\/robots.ts<\/a> file (or an edge worker on other stacks) compares the incoming request host against these settings. If the request comes from a staging or preview domain, the endpoint returns a blanket disallow directive:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>User-agent: *\nDisallow: \/<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For verified production hosts, the handler outputs standard allow rules plus the absolute path to your sitemap index.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">On Vercel, check <code>process.env.VERCEL_ENV === 'production'<\/code> in <code>app\/robots.ts<\/code> instead of matching hostnames. Vercel also sends an <code>X-Robots-Tag: noindex<\/code> header on <a href=\"https:\/\/vercel.com\/kb\/guide\/are-vercel-preview-deployment-indexed-by-search-engines\">preview deployments<\/a> by default, but it omits that header when a custom domain is assigned to a non-production branch, so a custom staging domain still needs the guard above.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"structured-data-and-image-seo-in-sanity\">Structured Data and Image SEO in Sanity<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Sanity lets the frontend build structured data and image markup from content editors already maintain, so neither needs separate manual entry. JSON-LD is generated at render time from document fields, alt text is required by schema validation, Sanity&#8217;s image CDN serves modern formats at the right size, and share images can be generated from each title.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"programmatic-json-ld-from-the-sanity-content-graph\"><span class=\"underline-accent\">Programmatic JSON-LD from the Sanity Content Graph<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The presentation layer should build JSON-LD at render time from document fields already in Sanity, so the structured markup matches what&#8217;s visible on the page. Inject the payload with a standard <code>&lt;script type=\"application\/ld+json\"&gt;<\/code> tag; typical types include Article for blog posts, Person for authors, and Organization and BreadcrumbList site-wide. Valid <a href=\"https:\/\/developers.google.com\/search\/docs\/appearance\/structured-data\/intro-structured-data\">structured data<\/a> does not ensure a rich result, but it tells search engines in a machine-readable form what the page is about.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"generated-open-graph-images\"><span class=\"underline-accent\">Generated Open Graph Images<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Editors rarely design a share image for every post. In the Next.js App Router, an <a href=\"https:\/\/nextjs.org\/docs\/app\/api-reference\/file-conventions\/metadata\/opengraph-image\">opengraph-image.tsx<\/a> file in a dynamic route can fetch the document from Sanity and render a 1200&#215;630 card with <code>ImageResponse<\/code> from <code>next\/og<\/code>, so every page has a share image even when nobody uploaded one:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ app\/blog\/&#91;slug]\/opengraph-image.tsx\nimport { ImageResponse } from 'next\/og';\nimport { client } from '@\/sanity\/client';\n\nexport const size = { width: 1200, height: 630 };\nexport const contentType = 'image\/png';\n\nexport default async function Image({ params }: { params: Promise&lt;{ slug: string }&gt; }) {\n const { slug } = await params;\n const post = await client.fetch('*&#91;_type == \"post\" &amp;&amp; slug.current == $slug]&#91;0]{ title }', { slug });\n return new ImageResponse(\n &lt;div style={{ display: 'flex', width: '100%', height: '100%', alignItems: 'center', padding: 80, fontSize: 64, background: 'white' }}&gt;\n {post?.title}\n &lt;\/div&gt;,\n { ...size }\n );\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Next.js caches the generated image by default and adds the <code>og:image<\/code> tags with their width and height to the page head.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"alt-text-validation-and-descriptive-filenames\"><span class=\"underline-accent\">Alt Text Validation and Descriptive Filenames<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Configure Sanity Studio schemas to require descriptive alt text before an editor can publish an entry, so no unlabeled image reaches the public site:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>defineField({\n name: 'altText',\n title: 'Alternative Text',\n type: 'string',\n validation: (Rule) =&gt; Rule.required().error('Alt text is required for SEO and accessibility.'),\n})<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Sanity stores media as asset records with hashed filenames, but its <a href=\"https:\/\/www.sanity.io\/docs\/apis-and-sdks\/image-urls\">image URLs<\/a> accept a descriptive vanity filename appended to the path, such as hero-blog-seo-best-practices.png.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"next-gen-formats-and-responsive-breakpoints\"><span class=\"underline-accent\">Next-Gen Formats and Responsive Breakpoints<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Sanity transforms images on the fly from its asset CDN, using parameters such as <code>?w=800&amp;fm=webp&amp;q=80<\/code>. Adding <code>?auto=format<\/code> returns the most optimized format the browser supports, such as WebP or AVIF. Combine the Sanity image URL builder with components such as next\/image to request exact dimensions per breakpoint, and use the asset&#8217;s aspect ratio and hotspot crop data to avoid layout shifts. The <a href=\"https:\/\/pagepro.co\/blog\/nextjs-image-component-performance-cwv\/\">Next.js image component guide<\/a> covers the frontend side.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"performance-and-rendering-strategy-for-seo\">Performance and Rendering Strategy for SEO<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Pages that need to rank on a Sanity site should reach crawlers as fully rendered HTML, because pure client-side rendering can delay indexing when bots defer JavaScript. Pick a rendering pattern per page type, serve pages from the cache, refresh them through Sanity webhooks when content changes, and keep Core Web Vitals within Google&#8217;s thresholds on mobile.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"choose-a-rendering-pattern-per-page-type\"><span class=\"underline-accent\">Choose a Rendering Pattern per Page Type<\/span><\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Static Site Generation (SSG):<\/strong> Pre-renders evergreen articles and resource libraries into static HTML at build time, so CDNs serve crawlers and visitors instantly.<\/li>\n\n\n\n<li><strong>Incremental Static Regeneration (ISR):<\/strong> Keeps content fresh without full rebuilds by regenerating individual cached pages when editors update Sanity documents.<\/li>\n\n\n\n<li><strong>Server-Side Rendering (SSR):<\/strong> Renders pages on request. It suits real-time inventory, search filters, or personalized pages.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/pagepro.co\/blog\/nextjs-rendering-strategies-ssr-ssg-isr-csr\/\">Next.js rendering strategies guide<\/a> compares these three with client-side rendering in detail.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"webhook-driven-revalidation\"><span class=\"underline-accent\">Webhook-Driven Revalidation<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Instead of calling the Sanity API on every request, serve pages from the cache and invalidate them when content changes. Revalidation only works on data that carries a cache tag, so tag every Sanity query when you fetch it, for example <code>client.fetch(query, params, { next: { tags: ['post', `post:${slug}`] } })<\/code>. <a href=\"https:\/\/www.sanity.io\/docs\/content-lake\/webhooks\">Sanity webhooks<\/a>, filtered with GROQ, can then call a Next.js route that runs <a href=\"https:\/\/nextjs.org\/docs\/app\/api-reference\/functions\/revalidateTag\">revalidateTag<\/a> for those tags:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ Example Next.js App Router on-demand revalidation handler\nimport { revalidateTag } from 'next\/cache';\nimport { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity\/webhook';\nexport async function POST(req) {\n const body = await req.text();\n const signature = req.headers.get(SIGNATURE_HEADER_NAME);\n if (!(await isValidSignature(body, signature, process.env.SANITY_WEBHOOK_SECRET))) {\n return new Response('Invalid signature', { status: 401 });\n }\n const { _type, slug } = JSON.parse(body);\n revalidateTag(_type, 'max');\n if (slug?.current) revalidateTag(`${_type}:${slug.current}`, 'max');\n return Response.json({ revalidated: true, now: Date.now() });\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Tag-based revalidation marks only the targeted document and its related listings as stale. With the recommended <code>'max'<\/code> profile, the next visitor gets the cached page while a fresh one renders; pass <code>{ expire: 0 }<\/code> instead if an edit must disappear from the cache at once.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"core-web-vitals-in-headless-frontends\"><span class=\"underline-accent\">Core Web Vitals in Headless Frontends<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/web.dev\/articles\/vitals\">Core Web Vitals<\/a> feed Google&#8217;s <a href=\"https:\/\/developers.google.com\/search\/docs\/appearance\/page-experience\">page experience<\/a> signals, and a headless frontend still has to earn good scores:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Largest Contentful Paint (LCP):<\/strong> Load the above-the-fold hero elements within 2.5 seconds by prioritizing banner assets, using edge CDN endpoints, and preloading primary font files.<\/li>\n\n\n\n<li><strong>Interaction to Next Paint (INP):<\/strong> Keep page responsiveness under 200 milliseconds by code-splitting heavy third-party scripts, deferring analytics bundles, and minimizing main-thread JavaScript.<\/li>\n\n\n\n<li><strong>Cumulative Layout Shift (CLS):<\/strong> Keep visual stability scores below 0.1 by setting explicit aspect ratios on embedded assets and fixed-dimension containers for dynamic Sanity content blocks.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/pagepro.co\/blog\/vercel-hosting-platform\/\">If you host on Vercel<\/a>, <a href=\"https:\/\/vercel.com\/docs\/speed-insights\">Speed Insights<\/a> reports performance from real visitors per route, so you can catch a regression from a new Sanity module before it shows up in Search Console; individual Core Web Vitals per route need Speed Insights Plus.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pagepro moved <a href=\"https:\/\/pagepro.co\/case-studies\/gpnotebook\">GP Notebook<\/a>, a medical reference platform used by UK GPs, from a legacy custom CMS to Next.js and Sanity, and the replatformed site passes Core Web Vitals on mobile, which the legacy site never could (CrUX field data).<\/p>\n\n\n\n<div class=\"wp-block-code-mind-cta c-cta-block\" style=\"background-color:#00141F;color:#FFFFFF\"><div class=\"c-cta-block__content\"><p class=\"c-cta-block__title\">Need an editorial setup Google actually loves?<\/p><div class=\"c-cta-block__action\"><a href=\"https:\/\/pagepro.co\/contact\" class=\"c-cta-block__button ga-cta ga-cta-consultation theme-bg-3\">Build with Pagepro<\/a><\/div><\/div><\/div>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"localization-and-hreflang-in-sanity\">Localization and hreflang in Sanity<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Multilingual Sanity sites need explicit URL paths, self-referential canonicals and hreflang tags for every language version, because Sanity doesn&#8217;t manage routing out of the box. First choose between field-level and document-level localization, which decides how GROQ fetches each language, then render one crawlable URL per locale with alternates pointing to every sister version.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"field-level-vs-document-level-localization\"><span class=\"underline-accent\">Field-Level vs. Document-Level Localization<\/span><\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Field-Level Localization:<\/strong> Embeds language variations in individual field objects within a single document. It suits product catalogs where specifications, IDs, and images stay the same, and only titles and descriptions need translation.<\/li>\n\n\n\n<li><strong>Document-Level Localization:<\/strong> Duplicates whole documents for each target locale and links the variants through translation metadata or internationalization plugins. It fits localized blogs and regional marketing sites that need different editorial structures, images, or publishing schedules.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">GROQ queries both structures: field-level implementations project language-specific values (such as <code>title[$locale]<\/code>), while document-level setups filter by a locale property (such as <code>*[_type == \"page\" &amp;&amp; locale == $locale]<\/code>).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"hreflang-tags-canonical-urls-and-routing\"><span class=\"underline-accent\">hreflang Tags, Canonical URLs, and Routing<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Expose a unique, crawlable URL path for each locale (such as \/en\/, \/es\/, or \/de\/); search bots won&#8217;t find locale variants switched by cookies or headers. Each regional page should carry a canonical link pointing to its own absolute URL, never back to an English master page, plus <a href=\"https:\/\/developers.google.com\/search\/docs\/specialty\/international\/localized-versions\">hreflang annotations<\/a> linking to all sister versions and an x-default target.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"author-signals-and-freshness-for-e-e-a-t\">Author Signals and Freshness for E-E-A-T<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Crawlers can&#8217;t judge who wrote a page unless you model authors as entities and render that context into visible text and structured data. In Sanity, that means an author document referenced from every post and mapped to Person JSON-LD, plus publication and revision dates that editors set deliberately instead of relying on automatic timestamps.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Author Schema:<\/strong> Define an author document type with name, job title, credentials, an avatar, a short bio, and links to profiles such as LinkedIn, and reference it from posts instead of free-text author strings.<\/li>\n\n\n\n<li><strong>Linked Person JSON-LD:<\/strong> Map author fields to schema.org Person data on author pages and article pages, filling the <code>sameAs<\/code> array with verified profile links.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">For freshness, don&#8217;t let <code>_updatedAt<\/code> pass for an editorial revision: schema migrations and typo fixes change it too. Add a custom <code>publishedAt<\/code> and an updated-date field that editors set only after a real revision, show both dates on the page, and use the same values for <code>datePublished<\/code> and <code>dateModified<\/code> in JSON-LD and for <code>lastmod<\/code> in the sitemap.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"how-to-audit-and-govern-sanity-seo\">How to Audit and Govern Sanity SEO<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Sanity separates the editorial environment from the presentation layer, so SEO regressions can slip in silently with routine code deployments or schema changes. Two habits catch them: role-based permissions that keep canonical, robots and noindex settings in the right hands, and a weekly crawl plus Search Console checks that confirm what Googlebot actually receives.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"roles-and-publishing-controls\"><span class=\"underline-accent\">Roles and Publishing Controls<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Role-Based Access Control (RBAC)<\/strong> in Sanity lets teams limit sensitive SEO controls to the right people:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Global Metadata Protection:<\/strong> Limit canonical overrides, robots.txt settings, and noindex toggles to administrators or technical SEO managers.<\/li>\n\n\n\n<li><strong>Author and Contributor Scopes:<\/strong> Give freelance authors edit rights for drafts and article bodies, and withhold publish rights.<\/li>\n\n\n\n<li><strong>Dataset Isolation:<\/strong> Enforce separate access rules for staging and production datasets, so experimental content doesn&#8217;t leak into production feeds.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"technical-crawls-and-ci-lighthouse-thresholds\"><span class=\"underline-accent\">Technical Crawls and CI Lighthouse Thresholds<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Run a crawler such as Screaming Frog or Sitebulb weekly across staging and production builds, and add Lighthouse thresholds to CI to catch performance regressions before deployment. Each crawl should check:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Index Directives and Status Codes:<\/strong> All public, canonical URLs return a clean HTTP 200, and staging noindex meta tags don&#8217;t carry over into production.<\/li>\n\n\n\n<li><strong>XML Sitemap Integrity:<\/strong> Sitemaps list only canonical URLs and leave out draft documents.<\/li>\n\n\n\n<li><strong>Canonical Alignment:<\/strong> No duplicate canonical references or conflicting trailing-slash redirects from frontend routing mismatches.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"monitoring-with-google-search-console\"><span class=\"underline-accent\">Monitoring with Google Search Console<\/span><\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The URL Inspection tool in Google Search Console shows the exact HTML Googlebot rendered, so you can confirm that dynamic title tags, meta descriptions, and JSON-LD blocks reach the document head. Give new topic clusters several weeks of crawling before you judge the ranking effect.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Pagepro, a <a href=\"https:\/\/pagepro.co\/services\/sanity-development\">Sanity development agency<\/a>, builds sites on its own Next.js and Sanity framework, built from 20+ real projects with zero drops in Google rankings across all of them. To see your own content on it before you commit, Pagepro offers to rebuild part of your website with your real content in 48 hours, with no cost and no commitment.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"faq\">FAQ<\/h2>\n\n\n\n<div class=\"schema-faq wp-block-yoast-faq-block\"><div class=\"schema-faq-section\" id=\"faq-question-1274753359950\"><strong class=\"schema-faq-question\">How do I set up SEO fields in a Sanity CMS schema?<\/strong> <p class=\"schema-faq-answer\">Define one reusable SEO object type and embed it in every indexable document schema. Include meta title and description fields, robots flags like noindex, a canonical override and an image field for social share previews. Add validation such as Rule.max(60) for titles and Rule.min(120).max(160) for descriptions.<\/p> <\/div> <div class=\"schema-faq-section\" id=\"faq-question-1156157273255\"><strong class=\"schema-faq-question\">Why does Sanity need a custom SEO object for meta tags?<\/strong> <p class=\"schema-faq-answer\">Sanity doesn&#8217;t output SEO markup on its own, and community SEO plugins still leave the rendering to your frontend, so most teams define a custom object to store meta titles, descriptions, canonical URLs and structured data. The frontend then turns that content into crawlable HTML that search engines can read.<\/p> <\/div> <div class=\"schema-faq-section\" id=\"faq-question-1340710440861\"><strong class=\"schema-faq-question\">What is the best title and description length for Sanity SEO?<\/strong> <p class=\"schema-faq-answer\">Keep meta titles between 50 and 60 characters and meta descriptions between 120 and 160 characters. These limits reduce truncation in search results and fit standard snippet sizes. Add validation in Sanity Studio so editors see length feedback before publishing.<\/p> <\/div> <div class=\"schema-faq-section\" id=\"faq-question-1308558782756\"><strong class=\"schema-faq-question\">How do I generate a dynamic sitemap from Sanity content?<\/strong> <p class=\"schema-faq-answer\">Use GROQ to query public slugs and timestamps from the Sanity Content Lake, excluding drafts and noindex documents, then generate the XML on your frontend or server. Take lastmod from a revision date editors set after real changes, and generate the file, for example in app\/sitemap.ts, from the same queries as production routing so it stays in sync.<\/p> <\/div> <div class=\"schema-faq-section\" id=\"faq-question-1285065508764\"><strong class=\"schema-faq-question\">How can I stop noindex pages appearing in Google search results?<\/strong> <p class=\"schema-faq-answer\">Add a noIndex boolean field to your SEO object and render &lt;meta name=&#8221;robots&#8221; content=&#8221;noindex, follow&#8221;> on pages where it is true. Also remove those pages from your sitemap, because listing a noindex page there sends conflicting signals to crawlers.<\/p> <\/div> <div class=\"schema-faq-section\" id=\"faq-question-1380028504265\"><strong class=\"schema-faq-question\">Which rendering method is best for SEO in a headless Sanity site?<\/strong> <p class=\"schema-faq-answer\">Server-side rendering (SSR) or static site generation (SSG) are best for SEO because they send fully rendered HTML to crawlers. Client-side rendering (CSR) can delay indexing if bots do not execute JavaScript, while SSG works well for evergreen content and SSR suits frequently changing or personalized pages.<\/p> <\/div> <\/div>\n","protected":false},"excerpt":{"rendered":"<p>How SEO Works in Headless Sanity CMS In a headless setup, SEO is built by your schemas and your frontend, not by the CMS. Sanity keeps content in its Content Lake and outputs no HTML of its own, so it won&#8217;t rescue or ruin your search visibility on its own. For a CTO, this means [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":24874,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"rop_custom_images_group":[],"rop_custom_messages_group":[],"rop_publish_now":"yes","rop_publish_now_accounts":[],"rop_publish_now_history":[],"rop_publish_now_status":"pending","footnotes":""},"categories":[309,673],"tags":[],"class_list":["post-24865","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-headless","category-sanity"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.4 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>SEO Best Practices for Sanity CMS - Pagepro<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"SEO Best Practices for Sanity CMS - Pagepro\" \/>\n<meta property=\"og:description\" content=\"How SEO Works in Headless Sanity CMS In a headless setup, SEO is built by your schemas and your frontend, not by the CMS. Sanity keeps content in its Content Lake and outputs no HTML of its own, so it won&#8217;t rescue or ruin your search visibility on its own. For a CTO, this means [&hellip;]\" \/>\n<meta property=\"og:url\" content=\"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/\" \/>\n<meta property=\"og:site_name\" content=\"Pagepro\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/thisispagepro\" \/>\n<meta property=\"article:author\" content=\"https:\/\/facebook.com\/krzysztof.lojniewski\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-20T08:12:54+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-11-09T18:39:02+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/pagepro.co\/blog\/wp-content\/uploads\/2026\/10\/SEO-Best-Practices-for-Sanity-CMS.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1280\" \/>\n\t<meta property=\"og:image:height\" content=\"720\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"Chris Lojniewski\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:creator\" content=\"@https:\/\/x.com\/chrislojniewski\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Chris Lojniewski\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"19 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/pagepro.co\\\/blog\\\/seo-best-practices-for-sanity-cms\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/pagepro.co\\\/blog\\\/seo-best-practices-for-sanity-cms\\\/\"},\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/pagepro.co\\\/blog\\\/seo-best-practices-for-sanity-cms\\\/\"},\"headline\":\"SEO Best Practices for Sanity CMS\",\"datePublished\":\"2026-10-20T08:12:54+00:00\",\"dateModified\":\"2026-11-09T18:39:02+00:00\",\"inLanguage\":\"en-US\",\"breadcrumb\":{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/pagepro.co\\\/blog\\\/seo-best-practices-for-sanity-cms\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Blog\",\"item\":\"https:\\\/\\\/pagepro.co\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Headless\",\"item\":\"https:\\\/\\\/pagepro.co\\\/blog\\\/category\\\/headless\\\/\"},{\"@type\":\"ListItem\",\"position\":3,\"name\":\"SEO Best Practices for Sanity CMS\"}]},\"publisher\":{\"@id\":\"https:\\\/\\\/pagepro.co\\\/#organization\"},\"articleSection\":\"Headless\",\"wordCount\":4289,\"image\":{\"@id\":\"https:\\\/\\\/pagepro.co\\\/blog\\\/seo-best-practices-for-sanity-cms\\\/#primaryimage\"},\"author\":{\"@id\":\"https:\\\/\\\/pagepro.co\\\/blog\\\/author\\\/chris-lojniewski\\\/#person\",\"name\":\"Chris Lojniewski\"}},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/pagepro.co\\\/#organization\",\"name\":\"Pagepro\",\"url\":\"https:\\\/\\\/pagepro.co\\\/\",\"foundingDate\":\"2016-02-27\",\"description\":\"Pagepro is a website replatforming and headless CMS migration agency. We move content-heavy websites to Next.js and Sanity, take over unfinished and AI-built Next.js projects, and provide continuous development and support.\",\"logo\":{\"@type\":\"ImageObject\",\"@id\":\"https:\\\/\\\/pagepro.co\\\/#logo\",\"url\":\"https:\\\/\\\/pagepro.co\\\/assets\\\/svg\\\/pagepro-full-color.svg\",\"width\":300,\"height\":132,\"caption\":\"Pagepro\"},\"sameAs\":[\"https:\\\/\\\/www.linkedin.com\\\/company\\\/pagepro\\\/\",\"https:\\\/\\\/x.com\\\/pagepro_agency\",\"https:\\\/\\\/www.facebook.com\\\/thisispagepro\",\"https:\\\/\\\/www.instagram.com\\\/pagepro_\\\/\",\"https:\\\/\\\/www.youtube.com\\\/@Pagepro_agency\",\"https:\\\/\\\/clutch.co\\\/profile\\\/pagepro\",\"https:\\\/\\\/www.sanity.io\\\/agency-partners\\\/pagepro\",\"https:\\\/\\\/github.com\\\/Pagepro\"],\"knowsAbout\":[\"Website replatforming\",\"Headless CMS migration\",\"Website and CMS migration\",\"Next.js development\",\"Sanity CMS development\",\"Headless CMS architecture\",\"AI-built application takeover\",\"Next.js support and maintenance\",\"Web application development\",\"React development\",\"React Native development\",\"Team augmentation\"],\"areaServed\":[{\"@type\":\"Country\",\"name\":\"United Kingdom\"},{\"@type\":\"Country\",\"name\":\"United States\"},{\"@type\":\"Country\",\"name\":\"Poland\"}],\"subOrganization\":[{\"@id\":\"https:\\\/\\\/pagepro.co\\\/#ltd\"},{\"@id\":\"https:\\\/\\\/pagepro.co\\\/#spzoo\"}]},{\"@type\":\"FAQPage\",\"mainEntity\":[{\"@type\":\"Question\",\"name\":\"How do I set up SEO fields in a Sanity CMS schema?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Define one reusable SEO object type and embed it in every indexable document schema. Include meta title and description fields, robots flags like noindex, a canonical override and an image field for social share previews. Add validation such as Rule.max(60) for titles and Rule.min(120).max(160) for descriptions.\"}},{\"@type\":\"Question\",\"name\":\"Why does Sanity need a custom SEO object for meta tags?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Sanity doesn't output SEO markup on its own, and community SEO plugins still leave the rendering to your frontend, so most teams define a custom object to store meta titles, descriptions, canonical URLs and structured data. The frontend then turns that content into crawlable HTML that search engines can read.\"}},{\"@type\":\"Question\",\"name\":\"What is the best title and description length for Sanity SEO?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Keep meta titles between 50 and 60 characters and meta descriptions between 120 and 160 characters. These limits reduce truncation in search results and fit standard snippet sizes. Add validation in Sanity Studio so editors see length feedback before publishing.\"}},{\"@type\":\"Question\",\"name\":\"How do I generate a dynamic sitemap from Sanity content?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Use GROQ to query public slugs and timestamps from the Sanity Content Lake, excluding drafts and noindex documents, then generate the XML on your frontend or server. Take lastmod from a revision date editors set after real changes, and generate the file, for example in app\\\/sitemap.ts, from the same queries as production routing so it stays in sync.\"}},{\"@type\":\"Question\",\"name\":\"How can I stop noindex pages appearing in Google search results?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Add a noIndex boolean field to your SEO object and render &lt;meta name=\\\"robots\\\" content=\\\"noindex, follow\\\"> on pages where it is true. Also remove those pages from your sitemap, because listing a noindex page there sends conflicting signals to crawlers.\"}},{\"@type\":\"Question\",\"name\":\"Which rendering method is best for SEO in a headless Sanity site?\",\"acceptedAnswer\":{\"@type\":\"Answer\",\"text\":\"Server-side rendering (SSR) or static site generation (SSG) are best for SEO because they send fully rendered HTML to crawlers. Client-side rendering (CSR) can delay indexing if bots do not execute JavaScript, while SSG works well for evergreen content and SSR suits frequently changing or personalized pages.\"}}]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"SEO Best Practices for Sanity CMS - Pagepro","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/","og_locale":"en_US","og_type":"article","og_title":"SEO Best Practices for Sanity CMS - Pagepro","og_description":"How SEO Works in Headless Sanity CMS In a headless setup, SEO is built by your schemas and your frontend, not by the CMS. Sanity keeps content in its Content Lake and outputs no HTML of its own, so it won&#8217;t rescue or ruin your search visibility on its own. For a CTO, this means [&hellip;]","og_url":"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/","og_site_name":"Pagepro","article_publisher":"https:\/\/www.facebook.com\/thisispagepro","article_author":"https:\/\/facebook.com\/krzysztof.lojniewski","article_published_time":"2026-10-20T08:12:54+00:00","article_modified_time":"2026-11-09T18:39:02+00:00","og_image":[{"width":1280,"height":720,"url":"https:\/\/pagepro.co\/blog\/wp-content\/uploads\/2026\/10\/SEO-Best-Practices-for-Sanity-CMS.png","type":"image\/png"}],"author":"Chris Lojniewski","twitter_card":"summary_large_image","twitter_creator":"@https:\/\/x.com\/chrislojniewski","twitter_misc":{"Written by":"Chris Lojniewski","Est. reading time":"19 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/#article","isPartOf":{"@id":"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/"},"mainEntityOfPage":{"@id":"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/"},"headline":"SEO Best Practices for Sanity CMS","datePublished":"2026-10-20T08:12:54+00:00","dateModified":"2026-11-09T18:39:02+00:00","inLanguage":"en-US","breadcrumb":{"@type":"BreadcrumbList","@id":"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Blog","item":"https:\/\/pagepro.co\/blog\/"},{"@type":"ListItem","position":2,"name":"Headless","item":"https:\/\/pagepro.co\/blog\/category\/headless\/"},{"@type":"ListItem","position":3,"name":"SEO Best Practices for Sanity CMS"}]},"publisher":{"@id":"https:\/\/pagepro.co\/#organization"},"articleSection":"Headless","wordCount":4289,"image":{"@id":"https:\/\/pagepro.co\/blog\/seo-best-practices-for-sanity-cms\/#primaryimage"},"author":{"@id":"https:\/\/pagepro.co\/blog\/author\/chris-lojniewski\/#person","name":"Chris Lojniewski"}},{"@type":"Organization","@id":"https:\/\/pagepro.co\/#organization","name":"Pagepro","url":"https:\/\/pagepro.co\/","foundingDate":"2016-02-27","description":"Pagepro is a website replatforming and headless CMS migration agency. We move content-heavy websites to Next.js and Sanity, take over unfinished and AI-built Next.js projects, and provide continuous development and support.","logo":{"@type":"ImageObject","@id":"https:\/\/pagepro.co\/#logo","url":"https:\/\/pagepro.co\/assets\/svg\/pagepro-full-color.svg","width":300,"height":132,"caption":"Pagepro"},"sameAs":["https:\/\/www.linkedin.com\/company\/pagepro\/","https:\/\/x.com\/pagepro_agency","https:\/\/www.facebook.com\/thisispagepro","https:\/\/www.instagram.com\/pagepro_\/","https:\/\/www.youtube.com\/@Pagepro_agency","https:\/\/clutch.co\/profile\/pagepro","https:\/\/www.sanity.io\/agency-partners\/pagepro","https:\/\/github.com\/Pagepro"],"knowsAbout":["Website replatforming","Headless CMS migration","Website and CMS migration","Next.js development","Sanity CMS development","Headless CMS architecture","AI-built application takeover","Next.js support and maintenance","Web application development","React development","React Native development","Team augmentation"],"areaServed":[{"@type":"Country","name":"United Kingdom"},{"@type":"Country","name":"United States"},{"@type":"Country","name":"Poland"}],"subOrganization":[{"@id":"https:\/\/pagepro.co\/#ltd"},{"@id":"https:\/\/pagepro.co\/#spzoo"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How do I set up SEO fields in a Sanity CMS schema?","acceptedAnswer":{"@type":"Answer","text":"Define one reusable SEO object type and embed it in every indexable document schema. Include meta title and description fields, robots flags like noindex, a canonical override and an image field for social share previews. Add validation such as Rule.max(60) for titles and Rule.min(120).max(160) for descriptions."}},{"@type":"Question","name":"Why does Sanity need a custom SEO object for meta tags?","acceptedAnswer":{"@type":"Answer","text":"Sanity doesn't output SEO markup on its own, and community SEO plugins still leave the rendering to your frontend, so most teams define a custom object to store meta titles, descriptions, canonical URLs and structured data. The frontend then turns that content into crawlable HTML that search engines can read."}},{"@type":"Question","name":"What is the best title and description length for Sanity SEO?","acceptedAnswer":{"@type":"Answer","text":"Keep meta titles between 50 and 60 characters and meta descriptions between 120 and 160 characters. These limits reduce truncation in search results and fit standard snippet sizes. Add validation in Sanity Studio so editors see length feedback before publishing."}},{"@type":"Question","name":"How do I generate a dynamic sitemap from Sanity content?","acceptedAnswer":{"@type":"Answer","text":"Use GROQ to query public slugs and timestamps from the Sanity Content Lake, excluding drafts and noindex documents, then generate the XML on your frontend or server. Take lastmod from a revision date editors set after real changes, and generate the file, for example in app\/sitemap.ts, from the same queries as production routing so it stays in sync."}},{"@type":"Question","name":"How can I stop noindex pages appearing in Google search results?","acceptedAnswer":{"@type":"Answer","text":"Add a noIndex boolean field to your SEO object and render &lt;meta name=\"robots\" content=\"noindex, follow\"> on pages where it is true. Also remove those pages from your sitemap, because listing a noindex page there sends conflicting signals to crawlers."}},{"@type":"Question","name":"Which rendering method is best for SEO in a headless Sanity site?","acceptedAnswer":{"@type":"Answer","text":"Server-side rendering (SSR) or static site generation (SSG) are best for SEO because they send fully rendered HTML to crawlers. Client-side rendering (CSR) can delay indexing if bots do not execute JavaScript, while SSG works well for evergreen content and SSR suits frequently changing or personalized pages."}}]}]}},"_links":{"self":[{"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/posts\/24865","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/comments?post=24865"}],"version-history":[{"count":8,"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/posts\/24865\/revisions"}],"predecessor-version":[{"id":25036,"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/posts\/24865\/revisions\/25036"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/media\/24874"}],"wp:attachment":[{"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/media?parent=24865"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/categories?post=24865"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/pagepro.co\/blog\/wp-json\/wp\/v2\/tags?post=24865"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}