Type something to search...
How to Manage SEO Metadata in Sanity

How to Manage SEO Metadata in Sanity

To manage SEO metadata in Sanity, create a reusable seo object type with fields such as meta title, meta description, social image, canonical URL and a no-index switch, and add it to every routable document type. Store site-wide defaults in a settings singleton, query the SEO fields with GROQ coalesce() fallbacks so every page has a title and description even when editors leave fields empty, and render them in your front end. In the Next.js App Router, that means returning the values from generateMetadata, with stega encoding turned off so invisible preview characters never reach search engines.

Sanity doesn't ship an SEO plugin or fixed SEO fields, because it doesn't know how your pages are built. That's an advantage: you decide what editors control, what falls back automatically and what validation keeps them on track. This guide covers modelling the fields, validation that guides rather than blocks, site defaults, fallback queries, rendering metadata in Next.js, canonical and robots handling, structured data and how to audit what you've published.

What Editors Should Control

Not every meta tag belongs in the CMS. A good split:

MetadataWho owns itWhere it lives
Meta titleEditors, with a fallback to the page titleseo.metaTitle
Meta descriptionEditors, with a fallback to the excerptseo.metaDescription
Social share imageEditors, with a fallback to the main imageseo.ogImage
No-indexEditors (for thin or private pages)seo.noIndex
Canonical URLUsually code; editors only for syndicated contentseo.canonicalUrl
Title template, site name, default imageSite ownerssiteSettings singleton
Language alternates, structured dataCode, from content fieldsGenerated

The rule of thumb: let editors override things that need human judgement, and derive the rest from content you already have.

Create a Reusable SEO Object

Define the fields once as an object type, so every document gets the same editing experience. The reusable object types post explains the pattern.

// schemaTypes/objects/seo.ts
import { defineField, defineType } from 'sanity'

export const seo = defineType({
  name: 'seo',
  title: 'SEO',
  type: 'object',
  options: { collapsible: true, collapsed: false },
  fields: [
    defineField({
      name: 'metaTitle',
      title: 'Meta title',
      type: 'string',
      description: 'Shown in search results and browser tabs. Leave empty to use the page title.',
      validation: (rule) => [
        rule.max(70).warning('Titles over 70 characters are often truncated in search results.'),
        rule.min(15).warning('Very short titles rarely describe the page well.'),
      ],
    }),
    defineField({
      name: 'metaDescription',
      title: 'Meta description',
      type: 'text',
      rows: 3,
      description: 'A one or two sentence summary for search results. Leave empty to use the excerpt.',
      validation: (rule) => [
        rule.max(160).warning('Descriptions over 160 characters may be cut off.'),
        rule.min(70).warning('Aim for at least 70 characters so the snippet is useful.'),
      ],
    }),
    defineField({
      name: 'ogImage',
      title: 'Social share image',
      type: 'image',
      description: 'Used when the page is shared on social platforms. 1200 × 630 pixels works best.',
      options: { hotspot: true },
    }),
    defineField({
      name: 'noIndex',
      title: 'Hide from search engines',
      type: 'boolean',
      initialValue: false,
      description: 'Adds a noindex robots tag and removes the page from the sitemap.',
    }),
    defineField({
      name: 'canonicalUrl',
      title: 'Canonical URL',
      type: 'url',
      description: 'Only set this if the content was first published elsewhere.',
      validation: (rule) => rule.uri({ scheme: ['https'] }),
    }),
  ],
})

Length limits are warnings, not errors. Search engines rewrite titles and descriptions anyway, and a hard limit that blocks publishing over one extra character frustrates editors. The validation rules guide covers warnings, errors and custom rules.

Add SEO to Your Documents

Include the object on every document type that has a URL, and put it in its own field group so it doesn't crowd the main editing view:

// schemaTypes/post.ts
import { defineField, defineType } from 'sanity'

export const post = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  groups: [
    { name: 'content', title: 'Content', default: true },
    { name: 'seo', title: 'SEO' },
  ],
  fields: [
    defineField({ name: 'title', type: 'string', group: 'content', validation: (rule) => rule.required() }),
    defineField({ name: 'slug', type: 'slug', group: 'content', options: { source: 'title' } }),
    defineField({ name: 'excerpt', type: 'text', rows: 3, group: 'content' }),
    defineField({ name: 'mainImage', type: 'image', group: 'content', options: { hotspot: true } }),
    defineField({ name: 'body', type: 'array', group: 'content', of: [{ type: 'block' }] }),
    defineField({ name: 'seo', type: 'seo', group: 'seo' }),
  ],
})

Register seo in your schema types array alongside the documents. Field groups are explained in field groups and fieldsets.

Store Site-Wide Defaults

Some values apply everywhere: the site name, a title template, a default description and a fallback share image. Keep them in a singleton document so editors can change them without a deploy. The singleton documents guide shows how to lock it to a single instance.

// schemaTypes/siteSettings.ts
import { defineField, defineType } from 'sanity'

export const siteSettings = defineType({
  name: 'siteSettings',
  title: 'Site settings',
  type: 'document',
  fields: [
    defineField({ name: 'siteName', type: 'string', validation: (rule) => rule.required() }),
    defineField({
      name: 'titleTemplate',
      type: 'string',
      description: 'Use %s for the page title, for example "%s | Acme".',
      initialValue: '%s | Acme',
    }),
    defineField({ name: 'defaultDescription', type: 'text', rows: 3 }),
    defineField({ name: 'defaultOgImage', type: 'image' }),
  ],
})

Query SEO Fields With Fallbacks

Resolve the fallbacks in GROQ, so the front end receives final values and every consumer behaves the same way:

// src/sanity/queries.ts
import { defineQuery } from 'next-sanity'

export const POST_SEO_QUERY = defineQuery(`{
  "post": *[_type == "post" && slug.current == $slug][0]{
    "title": coalesce(seo.metaTitle, title),
    "description": coalesce(seo.metaDescription, excerpt, pt::text(body[0...2])),
    "image": coalesce(seo.ogImage, mainImage),
    "noIndex": seo.noIndex == true,
    "canonicalUrl": seo.canonicalUrl,
    "slug": slug.current,
    _updatedAt
  },
  "settings": *[_id == "siteSettings"][0]{
    siteName,
    titleTemplate,
    defaultDescription,
    defaultOgImage
  }
}`)

What the fallbacks do:

  • coalesce() returns the first non-null value, so an empty meta title falls back to the document title.
  • pt::text() turns Portable Text into plain text. Taking the first two blocks gives a usable description when there's no excerpt.
  • seo.noIndex == true returns a real boolean even when the field has never been set.

The GROQ functions post covers coalesce(), pt::text() and friends.

Render Metadata in Next.js

In the App Router, set defaults in the root layout and override them per route with generateMetadata. Start with metadataBase and the title template:

// src/app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://www.example.com'),
  title: {
    default: 'Acme',
    template: '%s | Acme',
  },
  description: 'Acme builds tools for modern teams.',
}

metadataBase lets you use relative paths for canonicals and images. If you'd rather read the template from siteSettings, export a generateMetadata function from the layout instead.

Now the post route. In Next.js 16, params is a Promise, and the client used for metadata must have stega encoding disabled:

// src/app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { client } from '@/sanity/lib/client'
import { urlFor } from '@/sanity/lib/image'
import { POST_SEO_QUERY } from '@/sanity/queries'

type Props = { params: Promise<{ slug: string }> }

const metadataClient = client.withConfig({ stega: false })

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const { post, settings } = await metadataClient.fetch(POST_SEO_QUERY, { slug })

  if (!post) return { title: 'Not found', robots: { index: false } }

  const image = post.image ?? settings?.defaultOgImage
  const ogImageUrl = image
    ? urlFor(image).width(1200).height(630).fit('crop').format('jpg').url()
    : undefined

  return {
    title: post.title ?? undefined,
    description: post.description ?? settings?.defaultDescription ?? undefined,
    alternates: {
      canonical: post.canonicalUrl ?? `/blog/${post.slug}`,
    },
    robots: post.noIndex ? { index: false, follow: true } : undefined,
    openGraph: {
      type: 'article',
      title: post.title ?? undefined,
      description: post.description ?? undefined,
      url: `/blog/${post.slug}`,
      siteName: settings?.siteName ?? undefined,
      images: ogImageUrl ? [{ url: ogImageUrl, width: 1200, height: 630 }] : undefined,
    },
    twitter: { card: 'summary_large_image' },
  }
}

Social image handling, including generated images, is covered in Adding Open Graph Images With Sanity.

Why Stega Must Be Off

When Visual Editing is enabled, next-sanity can encode invisible characters into string fields so the Presentation tool can map text back to its source. Those characters are harmless on screen but break titles and descriptions in search results. Disable stega for metadata queries with withConfig({ stega: false }), or clean individual values:

import { stegaClean } from 'next-sanity'

const cleanTitle = stegaClean(post.title)

If you use sanityFetch from defineLive, pass stega: false in generateMetadata. The Visual Editing setup explains where stega comes from.

Handle Canonicals and Robots

Canonical URLs tell search engines which version of a page is the original. Most of the time the canonical is simply the page's own URL, which code should generate. Editors only need to override it for content republished from elsewhere.

For robots:

  • Per page: the noIndex field becomes a noindex robots meta tag via metadata.robots.
  • Site-wide: use app/robots.ts to block non-production environments and point crawlers at your sitemap.
// src/app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  const isProduction = process.env.VERCEL_ENV === 'production'
  return {
    rules: isProduction ? { userAgent: '*', allow: '/' } : { userAgent: '*', disallow: '/' },
    sitemap: 'https://www.example.com/sitemap.xml',
  }
}

Make sure noIndex pages are also left out of your sitemap; Generating Dynamic Sitemaps With Sanity shows the filter. For multilingual sites, generate alternates.languages from your translation documents, as described in multilingual content in Sanity.

Add Structured Data

Structured data (JSON-LD) helps search engines understand articles, products and organisations. Generate it from content fields rather than asking editors to write JSON. This example assumes the page's own query returns publishedAt, _updatedAt, author and mainImage:

// Inside the post page component
const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: post.title,
  datePublished: post.publishedAt,
  dateModified: post._updatedAt,
  author: post.author ? { '@type': 'Person', name: post.author.name } : undefined,
  image: post.mainImage ? urlFor(post.mainImage).width(1200).url() : undefined,
}

return (
  <article>
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }}
    />
    {/* ...post content */}
  </article>
)

Replacing < prevents content from closing the script tag early. Use cleaned (non-stega) values here too.

Audit Your SEO Fields

Because SEO lives in structured fields, GROQ can find problems across the whole dataset in seconds. Run these in the Vision plugin:

// Posts with no meta description and no excerpt
*[_type == "post" && !defined(seo.metaDescription) && !defined(excerpt)]{ title, "slug": slug.current }
// Meta descriptions that are too long
*[_type == "post" && length(seo.metaDescription) > 160]{ title, "length": length(seo.metaDescription) }
// Pages hidden from search engines
*[seo.noIndex == true]{ _type, title, "slug": slug.current }
// Meta titles used by more than one post
*[_type == "post" && defined(seo.metaTitle)]{
  title,
  "metaTitle": seo.metaTitle,
  "uses": count(*[_type == "post" && seo.metaTitle == ^.seo.metaTitle])
}[uses > 1]

Review the results before launches and every few months. A dashboard widget or a scheduled script can surface the same queries to editors.


FAQ: SEO Metadata in Sanity

No. Sanity has no fixed SEO fields, so you model them yourself, usually as a reusable seo object added to each routable document type. That lets you choose exactly which values editors control and which are derived from content.

Use a warning. Search engines truncate and rewrite titles based on pixel width rather than an exact character count, so a hard error blocks publishing for little benefit. A warning around 60 to 70 characters guides editors without getting in their way.

They're probably stega-encoded characters from Visual Editing. Fetch metadata with a client configured with stega false, pass stega false to sanityFetch, or clean values with stegaClean before putting them in titles, descriptions or structured data.

GROQ is a good default. Using coalesce() in the query means every consumer, from the page to the sitemap and feeds, gets the same resolved values. Keep code-level fallbacks for values from other sources, such as site defaults set in environment variables.

Not usually. Generate the canonical from the page's own URL in code, which is correct for almost every page. A canonical field is only useful when content is syndicated and should point to another site.

Filter them out in the sitemap query, for example with a condition that seo.noIndex is not true. Keeping the robots tag and the sitemap driven by the same field ensures they never disagree.


Conclusion

Managing SEO in Sanity is a content modelling job. Create a reusable seo object with sensible warnings, add it to every routable document in its own field group, and keep site-wide defaults in a settings singleton. Resolve fallbacks in GROQ with coalesce(), so pages always have a title, description and image even when editors leave fields empty.

On the front end, render those values through generateMetadata with stega disabled, set metadataBase once, and let the same fields drive robots tags, sitemaps and structured data. Finish with a few audit queries in Vision, and you have SEO that editors can manage confidently and developers never need to hard-code.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper