@crawl-me-maybe/sanity-plugin-seo
v0.1.3
Published
Readme
@crawl-me-maybe/sanity-plugin-seo
SEO fields, global defaults, and social preview cards for Sanity Studio.
Built for Sanity v5 and v6
Designed to pair with @crawl-me-maybe/meta on the frontend. The globalSeoSettings document and pageMetadata object use the same field names and shapes that buildMetadata() expects — query your content, resolve image URLs in GROQ, and pass the result straight through.
Table of contents
Install
npm install @crawl-me-maybe/sanity-plugin-seo
pnpm add @crawl-me-maybe/sanity-plugin-seo
bun add @crawl-me-maybe/sanity-plugin-seo
yarn add @crawl-me-maybe/sanity-plugin-seoFeatures
- Global defaults — a
globalSeoSettingssingleton document that feeds fallback values to all page-level fields; editors always see what fallback is being displayed - Page metadata — a
pageMetadataobject type with meta description, meta image, and search indexing controls that each show and override the global default - Social preview cards — live editable previews for Facebook, Twitter/X, LinkedIn, and Google Search, built from page data
- Favicon browser-tab preview — renders your favicon and site title in a mock browser tab with light/dark toggle
- Robots.txt rules editor — structured rule builder with a read-only
robots.txtpreview tab
Getting started
Plugin configuration
Pass options to the plugin to enable or disable individual features. All options default to true.
// sanity.config.ts
import crawlMeMaybeSeo from "@crawl-me-maybe/sanity-plugin-seo";
export default defineConfig({
plugins: [
crawlMeMaybeSeo({
global: {
favicon: true, // favicon field + browser-tab preview in Global Settings
robots: true, // robots.txt rule builder in Global Settings
},
page: {
searchIndexing: true, // noIndex / noFollow controls on page metadata
canonicalUrl: true, // canonical URL override on page metadata
},
}),
],
});| Option | Type | Default | Description |
|---|---|---|---|
| global.favicon | boolean | true | Includes the favicon field (with browser-tab preview) in Global SEO Settings |
| global.robots | boolean | true | Includes the robots rules builder in Global SEO Settings |
| page.searchIndexing | boolean | true | Includes noIndex / noFollow controls on the pageMetadata field |
| page.canonicalUrl | boolean | true | Includes the canonical URL field on pageMetadata. Disable if you manage canonical via a custom reference field on the document |
| fieldTypes.metaDescription | string | "text" | Base Sanity type for the shared metaDescription field |
| fieldTypes.metaTitle | string | "string" | Base Sanity type for the shared metaTitle field |
| resolveValue | (value: unknown) => unknown | identity | Studio helper that turns stored field values into scalars for placeholders, hasContent, and social previews |
Custom canonical URLs
By default, pageMetadata includes a canonicalUrl field for explicit URL overrides. Disable it when you prefer a reference to another document:
// sanity.config.ts
crawlMeMaybeSeo({
page: {
canonicalUrl: false,
},
});Then add your own field on the page document:
defineField({
name: "canonical",
type: "reference",
to: [{ type: "page" }, { type: "post" }],
})Resolve the referenced document to a URL in your frontend query and pass it to @crawl-me-maybe/meta with disableSelfCanonical: true. When the built-in field is enabled, leave it empty and @crawl-me-maybe/meta generates a self-referential canonical from siteUrl + slug automatically.
Adding SEO fields to a page document
Include the pageMetadata object type on any document schema. Global defaults are displayed automatically in Studio; use @crawl-me-maybe/meta's buildMetadata() on the frontend to apply the same merge logic.
// schemas/page.ts
import { defineType, defineField } from "sanity";
export default defineType({
name: "page",
title: "Page",
type: "document",
fields: [
defineField({
name: "title",
title: "Title",
type: "string",
}),
defineField({
name: "slug",
title: "Slug",
type: "slug",
options: { source: "title" },
}),
defineField({
name: "seo",
title: "SEO",
type: "pageMetadata", // registered by the plugin
}),
],
});Each pageMetadata field shows the live global default as a placeholder when the field is empty, and renders social preview cards (Facebook, Twitter/X, LinkedIn, Google) in a tabbed panel.
Studio structure — surfacing Global SEO Settings
Register the plugin, then add the globalSeoSettings singleton to your Studio's structure so editors can reach it:
// sanity.config.ts
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import crawlMeMaybeSeo from "@crawl-me-maybe/sanity-plugin-seo";
export default defineConfig({
plugins: [
structureTool({
structure: (S) =>
S.list()
.title("Content")
.items([
// ... your other list items ...
S.divider(),
S.listItem()
.title("Global SEO Settings")
.child(
S.document()
.schemaType("globalSeoSettings")
.documentId("globalSeoSettings")
),
]),
}),
crawlMeMaybeSeo(),
],
});Field-level i18n (i18n-fields)
When using sanity-plugin-i18n-fields, swap the base types for metaDescription / metaTitle and provide a resolveValue so Studio can read a locale string out of the stored locale object.
i18n.text / i18n.string store values like { _type: "i18n.text", en: "...", fr: "..." } instead of plain strings. Without resolveValue, global-default placeholders and social preview cards cannot display those values.
// sanity.config.ts
import { defineConfig } from "sanity";
import crawlMeMaybeSeo from "@crawl-me-maybe/sanity-plugin-seo";
import { I18nFields } from "sanity-plugin-i18n-fields";
export default defineConfig({
plugins: [
I18nFields({
locales: [
{ code: "en", title: "English", default: true },
{ code: "fr", title: "French" },
],
}),
crawlMeMaybeSeo({
fieldTypes: {
metaDescription: "i18n.text",
metaTitle: "i18n.string",
},
resolveValue: (value) => {
if (typeof value === "string") return value;
if (value && typeof value === "object" && !Array.isArray(value)) {
// Locale used for Studio placeholders + social previews
return (value as { en?: string }).en ?? null;
}
return null;
},
}),
],
});resolveValue only affects Studio UI (placeholders, empty-state messaging, and preview cards). On the frontend, resolve the active locale before passing data to @crawl-me-maybe/meta, which expects strings:
*[_type == "page" && slug.current == $slug][0]{
title,
"slug": { "current": slug.current },
"description": coalesce(seo.description[$locale], seo.description.en),
"canonicalUrl": seo.canonicalUrl,
"searchIndexing": seo.searchIndexing,
"metaImage": seo.metaImage.asset->url
}*[_type == "globalSeoSettings"][0]{
siteTitle,
pageTitleTemplate,
"metaDescription": coalesce(metaDescription[$locale], metaDescription.en),
siteUrl,
twitterHandle,
"defaultMetaImage": defaultMetaImage.asset->url,
"faviconUrl": favicon.asset->url
}Favicons
Browser-tab preview for dynamic favicons with theme-switching button. Can be disabled by setting global.favicon to false.
Robots.txt
Preview-enabled array field for robots.txt entries. Disabled by setting global.robots to false.
🧠 Note: This plugin only generates a preview, you are still responsible for exposing and formatting this to your frontend.
Frontend integration
This plugin only stores SEO metadata inside Sanity. Your app is responsible for reading these fields and rendering meta tags, canonical URLs, schema markup, robots.txt, and sitemap.xml.
@crawl-me-maybe/meta handles the metadata side — merging page and global defaults, title templates, Open Graph, Twitter cards, and canonical URLs. @crawl-me-maybe/sitemap and @crawl-me-maybe/schema cover sitemaps, robots.txt, and JSON-LD.
With @crawl-me-maybe/meta
The plugin schemas map directly to buildMetadata() inputs — no adapter layer required:
| Plugin schema | buildMetadata argument | Notes |
|---|---|---|
| globalSeoSettings | globalSeoDefaults (GlobalSeoSettings) | siteTitle, pageTitleTemplate, siteUrl, metaDescription, twitterHandle |
| globalSeoSettings.defaultMetaImage | defaultMetaImage | Resolve to a URL string in GROQ (defaultMetaImage.asset->url) |
| globalSeoSettings.favicon | faviconUrl | Resolve to a URL string in GROQ (favicon.asset->url) |
| Page title + slug | page.title, page.slug | Required for title templates and self-canonical URLs |
| pageMetadata.description | page.description | Falls back to globalSeoSettings.metaDescription |
| pageMetadata.metaImage | page.metaImage | Resolve to a URL string in GROQ; falls back to defaultMetaImage |
| pageMetadata.canonicalUrl | page.canonicalUrl | Path or full URL; empty values become a self-canonical from siteUrl + slug |
| pageMetadata.searchIndexing | page.searchIndexing | noIndex / noFollow become a robots meta tag |
Page query:
*[_type == "page" && slug.current == $slug][0]{
title,
"slug": { "current": slug.current },
"description": seo.description,
"canonicalUrl": seo.canonicalUrl,
"searchIndexing": seo.searchIndexing,
"metaImage": seo.metaImage.asset->url
}Global defaults query:
*[_type == "globalSeoSettings"][0]{
siteTitle,
pageTitleTemplate,
metaDescription,
siteUrl,
twitterHandle,
"defaultMetaImage": defaultMetaImage.asset->url,
"faviconUrl": favicon.asset->url
}Next.js example:
// app/[slug]/page.tsx
import type { Metadata } from "next";
import { buildMetadata } from "@crawl-me-maybe/meta";
import { toNextMeta } from "@crawl-me-maybe/meta/next";
import { client } from "@/lib/sanity";
export async function generateMetadata({ params }): Promise<Metadata> {
const page = await client.fetch(PAGE_QUERY, { slug: params.slug });
const seoDefaults = await client.fetch(GLOBAL_SEO_QUERY);
return toNextMeta(buildMetadata(page, seoDefaults));
}See the @crawl-me-maybe/meta docs for Nuxt, raw HTML, canonical URL options, and build-time overrides.
Schemas
globalSeoSettings document
A singleton document that provides site-wide defaults. Page-level fields display these values in Studio when empty; @crawl-me-maybe/meta's buildMetadata() applies the same fallbacks on the frontend.
| Field | Sanity type | Required | Validation | Notes |
|---|---|---|---|---|
| siteTitle | string | ✅ | Required | Injected into pageTitleTemplate via {siteTitle} |
| pageTitleTemplate | string | ✅ | Required | Custom token input; initial value {pageTitle} - {siteTitle} |
| siteUrl | url | ✅ | Must start with https:// | Used when generating canonical URLs, Open Graph URLs, and sitemap entries. |
| metaDescription | metaDescription | — | Warn < 120 or > 160 chars | Default description displayed by page metadata fields |
| defaultMetaImage | metaImage | — | — | Default OG image displayed by page metadata fields |
| favicon | image | — | — | Browser-tab preview in Studio. Controlled by global.favicon option |
| twitterHandle | string | — | Must start with @ | Social group |
| logo | image | — | — | Used for Organization / WebSite schema markup |
| advanced.robots | robots[] | — | Paths must start with / | robots.txt rule builder with preview tab. Controlled by global.robots option |
pageMetadata object
Add this type to any document that needs per-page SEO. Fields display the active global default as placeholder when empty.
| Field | Sanity type | Required | Validation | Notes |
|---|---|---|---|---|
| description | metaDescription | — | Warn < 120 or > 160 chars | Overrides globalSeoSettings.metaDescription |
| canonicalUrl | string | — | Path (/about) or https:// URL | Optional override; leave empty for auto self-canonical on the frontend. Controlled by page.canonicalUrl option |
| searchIndexing | searchIndexing | — | — | noIndex / noFollow controls. Controlled by page.searchIndexing option |
| metaImage | metaImage | — | — | Overrides globalSeoSettings.defaultMetaImage; shows thumbnail of active default when empty |
Shared field types
These named types are registered by the plugin and can be reused in your own schemas:
| Type name | Base type | Description |
|---|---|---|
| metaDescription | text (configurable via fieldTypes.metaDescription) | 3-row textarea with 120–160 char warnings when base type is text |
| metaTitle | string (configurable via fieldTypes.metaTitle) | Single-line title field with 50–60 char warnings when base type is string |
| metaImage | image | Standard image field for OG/social use |
| favicon | image | Image with browser-tab preview component |
| searchIndexing | object | noIndex + noFollow boolean controls |
| robots | array | Rule builder with userAgent / allow / disallow fields and robots.txt preview |
