14 Sanity SEO Fields to Add to Your Content Schema

Plan 14 Sanity SEO fields with a reusable Studio schema, published-perspective GROQ and checks for slugs, image assets, authors and indexing controls.

Editorial policy

Useful Sanity SEO fields give editors control over a page's identity, search metadata, sharing image and publication details. Keep the article's title, slug and editorial facts on the document, then put reusable search controls in an seo object. Your frontend must query those values and turn them into the intended HTML.

The 14 fields below form a practical model for an editorial website. They are custom schema choices, not a built-in Sanity SEO package or fourteen requirements for ranking in Google. Some are optional overrides, and article dates or author references may be unnecessary on other page types.

The examples use Sanity Studio's schema APIs and GROQ. They are based on current documentation, with a complete schema specimen and a read-only query pattern, rather than results from a tested Sanity installation. Adapt the field names and public routes to your existing project.

Choose where the fields belong

Keep editorial content reusable beyond search. A title can supply the visible heading and listing card; an excerpt can describe an article on an index page. Search-specific overrides belong in a reusable object so each document type follows the same contract.

Sanity's schema documentation explains how document and object types are registered. Registering a type makes it available to Studio; it does not create a public route, head tag or structured-data script.

Field path Sanity type Intended output
title string Visible article heading
slug slug Public route input, stored as slug.current
excerpt text Article summary and default description
seo.title string Optional document-title override
seo.description text Optional search-description override
seo.canonicalUrl url Deliberate canonical exception
seo.noIndex boolean Search-indexing policy
seo.hideFromLists boolean Internal-listing policy
seo.image image Sharing image and asset reference
seo.image.alt string Description for that image usage
publishedAt datetime Real editorial publication date
modifiedAt datetime Optional meaningful revision date
author reference Real author identity
relatedArticles array of references Editorially selected internal links

Agree on required values before adding validation. A missing route or identity is different from an absent optional title override. The specimen below requires a sharing image as an editorial policy; that is not a universal Google requirement.

There is also a critical boundary: Sanity Studio validation runs in Studio. Mutations through the API or client libraries are not checked against those rules. Imports, scripts and older records therefore need their own checks before the frontend trusts them. Use Studio errors for essential editorial inputs and warnings for advice that should not block publication.

Model the route and editorial context

These first three fields describe the article itself. Keep their meaning consistent across the article page, navigation and search metadata.

1. Title: the visible article heading

Use title for the human-readable article name. Require meaningful text, not merely a value consisting of spaces. The frontend can use it for the main heading and related-article links.

A separate SEO title is useful only when the search-facing wording needs to differ. Otherwise, the same clear title can serve both purposes. Avoid making editors enter an identical title twice just to satisfy the schema.

For example, an article titled “Fixing Broken Product Links” can keep that visible heading while using a slightly more specific document title. Both should describe the same page. Google's title-link guidance emphasizes clear, descriptive titles; it does not establish a fixed character limit or promise that Google will use your exact wording.

2. Slug: the input for the public route

Use Sanity's slug type rather than assuming the value is a plain string. A stored value looks like this:

JSON
{
  "_type": "slug",
  "current": "fix-broken-product-links"
}

Your query and route builder should read slug.current. The options.source setting can generate a suggested slug from the title, but your application still decides whether it becomes /blog/{slug}/ or another path.

Check uniqueness in that actual route namespace. Sanity's slug reference says the default check is scoped to the document type and field path. An article and a different document type can therefore share a slug. If both occupy the same public path, customize the rule or separate their route prefixes.

Also distinguish generation settings from validation. maxLength limits default slug generation; it does not enforce the same limit on manually entered values or a custom slugifier. Validate the restrictions your route genuinely needs. If a published slug changes, implement the old-to-new redirect and update links; changing this field alone does not preserve the old URL.

3. Excerpt: a summary for readers

Use excerpt for a concise, authored description that works on a listing card or article preview. It can also supply the default meta description when no deliberate search override exists.

Keep it separate from the body rather than automatically cutting the first paragraph at an arbitrary character. A body introduction may contain setup, formatting or links that do not make a useful standalone summary.

In the example model, the excerpt is required and an SEO description is optional. That is a documented publishing policy: every article has an accurate summary, while editors can adjust the search description when needed. The renderer must apply the same policy consistently.

Add search and listing controls

Search metadata, canonical identity and internal discovery need separate controls. One generic “hide page” switch makes those decisions difficult to understand and maintain.

4. SEO title: an optional document-title override

Use seo.title when the HTML document title needs different wording from the visible title. Describe that relationship in Studio so editors understand what they are changing.

Treat an absent override as a deliberate instruction to use the article title. Treat an override containing only spaces as invalid input. This matters when querying with GROQ: coalesce() selects the first non-null value, not the first nonblank string. An empty string can therefore prevent a later title from being selected.

The query and normalization example below makes that distinction explicit. Do not quietly substitute a homepage title or another article's metadata when the document itself is missing.

5. SEO description: a precise search summary

Use seo.description for an optional description tailored to the page's search context. If it is absent, the model's agreed default is the article excerpt.

The description should accurately summarize what a visitor will find. Character guidance can help editing, but a hard count is not a guarantee against truncation. Google's snippet documentation explains that snippets can be drawn from page content and can differ by query.

Check the final meta tag after publication. Correct text inside Studio does not prove that the frontend has stopped emitting a generic site description.

6. Canonical URL: a controlled exception

Use seo.canonicalUrl only when the canonical should intentionally differ from the URL generated by your public routing policy. Most ordinary article pages can derive their canonical from a configured website origin and slug.current.

Store an absolute HTTP or HTTPS URL for an override, validate it, and make cross-domain exceptions deliberate. A URL field cannot determine whether another page is genuinely equivalent, create a redirect, or force Google's canonical selection.

Compare the rendered canonical with internal links, redirects and sitemap entries. Google's canonicalization documentation explains the signals involved. Avoid entering a preview or Studio URL merely because that is the address an editor currently sees.

7. No-index policy: a search instruction

Use an explicitly named boolean such as seo.noIndex. When true, your frontend must emit the intended noindex directive. When false, it does not guarantee indexing; it simply means this particular control is not requesting exclusion.

A no-indexed page can still be a published, accessible page. Protect private previews with authentication instead. Google's noindex guidance also explains why a robots.txt block is not a substitute: the crawler needs access to read the indexing instruction.

Require a deliberate policy value in your publishing workflow. A new-document initial value is helpful, but it is not evidence that older or imported documents have the field.

8. Hide from lists: an internal-navigation decision

Use seo.hideFromLists when editors need to keep a page out of selected internal listings, such as the main blog index. Define exactly which queries consume it. It should not automatically become a search-indexing instruction or access restriction.

noIndex hideFromLists Intended distinction
false false Normal listing candidate; evaluate other indexability rules
true false Can remain visible in a useful internal list while excluded from search
false true Hidden from the chosen lists; may still be indexable and need contextual links
true true Hidden from those lists and excluded from search; still not private

A sitemap query should respect the indexing policy rather than blindly copy a listing query. In particular, hiding a page from the blog index does not inherently make it non-indexable. Apply the URL-selection rules in Screpy's XML sitemap guide when deciding what belongs in the generated file.

Define sharing images and their descriptions

The image reference and its description answer different questions: which asset should be used, and what does it communicate in this particular context?

9. Sharing image: preserve the image object

Use an image field for seo.image. Sanity stores a reference to an asset document alongside usage-specific information such as crop and hotspot. Those are different from a finished public image URL.

The image-type reference documents custom fields and asset validation. If your editorial policy requires a sharing image, use required().assetRequired() together. An image-shaped object without an actual asset should not count as a completed upload.

Keep the complete image object available to the renderer when you need crop or hotspot transformations. Merely dereferencing asset->url gives you the asset URL; it does not apply the transformations described in Sanity's image URL documentation.

The example model requires an image for this editorial workflow. If your site permits articles without one, make that policy explicit and handle the missing value honestly instead of emitting a broken URL.

10. Image description: write it for the actual usage

Add a custom alt string inside the image field. A usage-specific description lets the same asset communicate different things in different articles without forcing one global description onto every placement.

Map it to the relevant output. A social image description can supply og:image:alt; if that image also appears in the article body, its <img> needs the appropriate alt attribute there. One does not automatically create the other.

Describe the image concisely rather than repeating a target keyword. Google's image SEO guidance explains how alt text helps describe an image. Body images that are decorative can correctly use empty alt text; do not copy this specimen's sharing-image requirements indiscriminately onto every image component. Verify the actual image and rendered attribute, not just that a field named alt exists.

Keep article identity and relationships explicit

Article dates, bylines and internal links are useful structured inputs. Model them where they can be maintained accurately, then reuse the same facts in visible content and machine-readable output.

11. Publication date: an editorial event

Use a datetime field such as publishedAt for the article's real publication date. Creating a record in Content Lake is a different event from publishing the finished article on your website.

Decide who records that event and when it becomes authoritative. Avoid an initial value that silently labels every new draft as already published. The frontend should display and serialize the agreed date consistently, including its timezone.

For article markup, Google's Article documentation covers the date and identity properties. A date entered in a field does not schedule a public release by itself.

12. Modification date: a meaningful revision

Use an optional modifiedAt for a substantive editorial revision. Keep it empty until that event occurs, and do not let it precede the publication date.

Sanity's system _updatedAt records document updates, which may include changes that are not meaningful revisions of the public article. If your workflow uses it for a visible updated date or sitemap timestamp, verify that those events actually match. Otherwise, maintain an editorial date separately.

Do not advance an updated date simply to make an unchanged page appear fresh. When a meaningful correction is published, update the content and date together, then check the visible date and structured data.

13. Author: a reference to a real identity

Use an author reference rather than repeating an unstructured name on every article. A shared author document can hold the real display name and a valid profile URL; the frontend can reuse them in the byline and appropriate structured data.

Sanity's reference documentation distinguishes strong and weak references. Strong references are the default and support referential integrity; weak references can point to a target that does not yet exist or has been removed. Studio also has publication behavior for referenced drafts.

Check what the published query actually resolves. Do not weaken a required author reference simply to get past an editorial blocker, or manufacture a person when an imported reference is broken. An existing author type can be reused; the minimal type in the specimen is only for projects that need one.

14. Related articles: explicit reference links

Use an array of article references when editors need to choose genuinely useful next reads. Each reference should identify a document, and your query should resolve its current title and slug.

The renderer must turn those values into real links. A stored reference alone does not create an anchor, and a changed slug is useful only if the frontend resolves the current route rather than a stale copied URL.

Check the published perspective for unavailable targets. Omit an unavailable optional recommendation and report it for repair; do not invent a destination or replace it with an unrelated article. Keep the main article's availability separate from this optional recommendation list. For large collections, apply a sensible editorial limit instead of returning an unrestricted relationship graph.

Add the fields to a Sanity Studio schema

This specimen keeps the fourteen field paths consistent. Save it as schemaTypes/seoArticle.js in a Studio project. It includes a minimal author type so the reference has a target; reuse your existing author type instead if one is already registered.

The title, excerpt, policy booleans, publication date, author and image asset are required here. Optional overrides may be absent, but cannot contain only whitespace. The six-related-article limit is an editorial choice. Preserve your existing body and business fields when adapting the model.

JavaScript
import {defineField, defineType} from 'sanity';

const nonBlankIfPresent = (value) => {
  if (value === undefined || value === null) return true;
  return typeof value === 'string' && value.trim().length > 0
    ? true
    : 'Enter meaningful text or remove the optional value';
};

const httpUrlIfPresent = (value) => {
  if (value === undefined || value === null) return true;
  try {
    const url = new URL(value);
    return ['https:', 'http:'].includes(url.protocol)
      ? true
      : 'Use an absolute HTTP or HTTPS URL';
  } catch {
    return 'Use an absolute HTTP or HTTPS URL';
  }
};

export const seoType = defineType({
  name: 'seo',
  title: 'SEO',
  type: 'object',
  fields: [
    defineField({
      name: 'title', type: 'string', title: 'SEO title override',
      description: 'Optional. Otherwise the article title is used.',
      validation: (rule) => rule.custom(nonBlankIfPresent),
    }),
    defineField({
      name: 'description', type: 'text', rows: 3,
      description: 'Optional. Otherwise the article excerpt is used.',
      validation: (rule) => rule.custom(nonBlankIfPresent),
    }),
    defineField({
      name: 'canonicalUrl', type: 'url',
      description: 'Optional exception to the generated public URL.',
      validation: (rule) => rule.custom(httpUrlIfPresent),
    }),
    defineField({
      name: 'noIndex', type: 'boolean',
      description: 'Request exclusion from search results. Not privacy.',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'hideFromLists', type: 'boolean',
      description: 'Hide from the selected internal article listings.',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'image', type: 'image', options: {hotspot: true},
      description: 'Sharing image required by this editorial workflow.',
      validation: (rule) => rule.required().assetRequired(),
      fields: [defineField({
        name: 'alt', type: 'string', title: 'Image description',
        validation: (rule) => rule.required().custom(nonBlankIfPresent),
      })],
    }),
  ],
});

export const authorType = defineType({
  name: 'author', type: 'document',
  fields: [
    defineField({
      name: 'name', type: 'string',
      validation: (rule) => rule.required().custom(nonBlankIfPresent),
    }),
    defineField({
      name: 'url', type: 'url', title: 'Public author profile URL',
      validation: (rule) => rule.required().custom(httpUrlIfPresent),
    }),
  ],
});

export const articleType = defineType({
  name: 'article', type: 'document',
  initialValue: {
    seo: {_type: 'seo', noIndex: false, hideFromLists: false},
  },
  fields: [
    defineField({
      name: 'title', type: 'string',
      validation: (rule) => rule.required().custom(nonBlankIfPresent),
    }),
    defineField({
      name: 'slug', type: 'slug', options: {source: 'title'},
      validation: (rule) => rule.required().custom((value) =>
        typeof value?.current === 'string' && value.current.trim()
          ? true : 'Generate or enter a nonblank slug'),
    }),
    defineField({
      name: 'excerpt', type: 'text', rows: 3,
      validation: (rule) => rule.required().custom(nonBlankIfPresent),
    }),
    defineField({
      name: 'publishedAt', type: 'datetime',
      description: 'Actual editorial publication date, not draft creation.',
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'modifiedAt', type: 'datetime',
      description: 'Optional date of a meaningful published revision.',
      validation: (rule) => rule.custom((value, {document}) => {
        if (!value || !document?.publishedAt) return true;
        return Date.parse(value) >= Date.parse(document.publishedAt)
          ? true : 'Revision date must not precede publication';
      }),
    }),
    defineField({
      name: 'author', type: 'reference', to: [{type: 'author'}],
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: 'relatedArticles', type: 'array',
      of: [{type: 'reference', to: [{type: 'article'}]}],
      validation: (rule) => rule.max(6),
    }),
    defineField({
      name: 'seo', type: 'seo',
      validation: (rule) => rule.required(),
    }),
    // Existing article content, outside the fourteen SEO field paths.
    defineField({name: 'body', type: 'array', of: [{type: 'block'}]}),
  ],
});

Import the definitions into your existing sanity.config.js and append them to its current schema.types array:

JavaScript
import {seoType, authorType, articleType} from './schemaTypes/seoArticle';

// Inside the existing defineConfig({...}) configuration:
schema: {
  types: [
    // Keep other existing types; avoid duplicate type names.
    seoType,
    authorType,
    articleType,
  ],
},

Merge the fields into an existing article type rather than registering a second one with the same name. Initial values help new documents; they do not backfill old records or repair API imports. Run your project's normal schema checks, review affected documents and keep runtime validation at the delivery boundary.

Project the same fields into your frontend

Query the values the renderer needs rather than assuming that Studio's form layout defines the API response. Save this exported GROQ query as articleQuery.js. It matches the specimen above and returns an array so the loader can detect ambiguous routes:

JavaScript
export const ARTICLE_QUERY = `
*[_type == "article" && slug.current == $slug] {
  _id,
  title,
  "slug": slug.current,
  excerpt,
  publishedAt,
  modifiedAt,
  "author": author->{_id, name, url},
  "relatedArticles": relatedArticles[]->{
    _id, title, "slug": slug.current
  },
  seo {
    title,
    description,
    canonicalUrl,
    noIndex,
    hideFromLists,
    image {
      ...,
      "assetUrl": asset->url,
      "dimensions": asset->metadata.dimensions
    }
  }
}
`;

The image projection preserves the original image object and adds asset information. Sanity's image presentation guide explains how an image builder can use crop and hotspot data. Use the matching transformed URL and dimensions when rendering a cropped image, rather than claiming the original dimensions describe every transformation.

For the public website, set the published perspective explicitly. Sanity's perspective documentation describes how it excludes unpublished variants and how the default changed with API version 2025-02-19. Keep authenticated draft previews separate; do not expose a preview token in a public bundle.

The following server-only example uses @sanity/client and imports that query. Supply the environment values shown. The non-CDN read is useful for this verification example; your production cache policy is a separate decision.

JavaScript
import {createClient} from '@sanity/client';
import {ARTICLE_QUERY} from './articleQuery.js';

for (const key of ['SANITY_PROJECT_ID', 'SANITY_DATASET', 'ARTICLE_SLUG']) {
  if (!process.env[key]) throw new Error(`Set ${key}`);
}

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID,
  dataset: process.env.SANITY_DATASET,
  apiVersion: '2025-02-19',
  perspective: 'published',
  useCdn: false,
  ...(process.env.SANITY_READ_TOKEN
    ? {token: process.env.SANITY_READ_TOKEN} : {}),
});

const matches = await client.fetch(ARTICLE_QUERY, {
  slug: process.env.ARTICLE_SLUG,
});
if (!Array.isArray(matches) || matches.length !== 1) {
  throw new Error('Expected exactly one published article for this route');
}
const article = matches[0];

const requireText = (value, field) => {
  if (typeof value !== 'string' || !value.trim()) {
    throw new Error(`Missing or invalid ${field}`);
  }
  return value.trim();
};
const optionalText = (value, field) => {
  if (value === undefined || value === null) return undefined;
  if (typeof value !== 'string') throw new Error(`Invalid ${field}`);
  return value.trim() || undefined;
};
const requireHttpUrl = (value, field) => {
  const url = new URL(requireText(value, field));
  if (!['https:', 'http:'].includes(url.protocol)) {
    throw new Error(`Invalid HTTP/HTTPS URL: ${field}`);
  }
  return url.href;
};

const title = requireText(article.title, 'title');
const excerpt = requireText(article.excerpt, 'excerpt');
const slug = requireText(article.slug, 'slug');
for (const key of ['noIndex', 'hideFromLists']) {
  if (typeof article.seo?.[key] !== 'boolean') {
    throw new Error(`Explicit SEO policy missing: ${key}`);
  }
}
if (!article.author?._id || !article.seo?.image?.asset?._ref) {
  throw new Error('Published author or image asset is unavailable');
}
requireText(article.author.name, 'author.name');
requireHttpUrl(article.author.url, 'author.url');
requireHttpUrl(article.seo.image.assetUrl, 'image asset URL');
requireText(article.seo.image.alt, 'image description');
if (typeof article.publishedAt !== 'string' ||
    !Number.isFinite(Date.parse(article.publishedAt))) {
  throw new Error('A valid editorial publication date is required');
}
if (article.modifiedAt !== undefined && article.modifiedAt !== null &&
    (typeof article.modifiedAt !== 'string' ||
     !Number.isFinite(Date.parse(article.modifiedAt)) ||
     Date.parse(article.modifiedAt) < Date.parse(article.publishedAt))) {
  throw new Error('Invalid editorial revision date');
}
const canonicalOverride = optionalText(article.seo.canonicalUrl, 'canonical URL');
if (canonicalOverride) requireHttpUrl(canonicalOverride, 'canonical URL');

const metadataInputs = {
  title: optionalText(article.seo.title, 'seo.title') ?? title,
  description: optionalText(article.seo.description, 'seo.description') ?? excerpt,
  slug,
  canonicalOverride,
  noIndex: article.seo.noIndex,
  hideFromLists: article.seo.hideFromLists,
  image: article.seo.image,
};

This read establishes the document contract; it is not a complete frontend or an import-security layer. Validate HTTP/HTTPS URLs again at the consuming boundary, apply your approved public-origin and route rules, and escape values through the renderer. Treat blank optional overrides as absent under the stated title/excerpt policy, while reporting imported blanks for cleanup. Missing required content remains an error.

Resolve relatedArticles separately as optional recommendations. A null target under the public perspective needs an editorial repair, not a fabricated URL. Render only valid published targets with real anchors.

Your listing query can use seo.hideFromLists != true; your sitemap-selection query should evaluate seo.noIndex and canonical eligibility independently. Audit older records before relying on those filters, since absent flags are not proof of a reviewed policy.

Finally, map metadataInputs to your framework's head/metadata API and use the same article for visible content and structured data. For a Next.js frontend, these dynamic metadata checks cover the framework-specific handoff.

Verify one published document end to end

Use an ordinary article and a difficult case, such as an imported document, an optional blank override or a related article whose target is not published. Compare Studio, the public-perspective response and the real public page.

Case Expected result
Optional SEO title is absent The agreed article title reaches the document title
An import supplies a blank required title or missing policy Delivery check reports the missing value; it does not claim the record is ready
A new draft has different metadata The public query continues to use the published version
An image object has no valid asset Editorial/runtime validation reports the missing asset
Author reference does not resolve Required identity is repaired before treating the article as complete
noIndex and hideFromLists differ Search directive and selected internal lists follow their separate policies
A related article changes slug The rendered link resolves its current public path
A meaningful revision is published Visible date and article markup reflect the same editorial event

On the actual public URL, check the heading, document title, description, canonical, robots directives and image descriptions. Check that the byline, dates and any Article markup describe the same visible article. For JavaScript delivery, Screpy's crawlability guide explains the source-versus-rendered-output checks.

Keep a record of the document ID, route, observed values and publication time. If Studio and the query agree but the page differs, investigate the template, build or cache. If the query is wrong, inspect its fields, references, dataset and perspective first.

That gives each field an accountable job. Editors supply accurate inputs, the schema explains the rules, and the frontend proves that those rules became the intended public page. Indexing and search performance are separate outcomes to measure afterward.

Put this guide into practice

Continue with the Screpy tools that match this article's workflow.

Related posts

Keep reading practical SEO guides from the Screpy blog.

View all posts