sanity-plugin-seo
Version:
Complete SEO toolkit for Sanity Studio with live scoring, AI suggestions, team workflows, and 30+ structured data types. Free and AI tiers live. Pro coming soon.
1,365 lines (1,151 loc) β’ 47 kB
Markdown

## β‘ Sanity Plugin SEO
[](https://www.npmjs.com/package/sanity-plugin-seo)
[](https://www.npmjs.com/package/sanity-plugin-seo)
[](https://www.typescriptlang.org/)
**Sanity Studio Compatibility:**
[](https://www.sanity.io/)
[](https://www.sanity.io/)
[](https://www.sanity.io/)
**Framework Support:**
[](https://nextjs.org/)
[](https://astro.build/)
[](https://vuejs.org/)
The complete SEO toolkit for Sanity Studio. Empower your team with live SEO scoring, AI-powered content suggestions, team workflows, and comprehensive structured data support.
**Production-Ready:** Free and AI tiers live. Pro features coming soon with team workflows, bulk optimization, and schema management.
## Demo Video

## Complete Feature Set
Everything from basic SEO optimization to advanced team workflows.
| Feature | Free | AI | π Pro |
| ---------------------------------------------------------------- | :--: | :-: | :----: |
| Live SEO Score (0β100) | β
| β
| β |
| GEO Checklist (AI Overview readiness) | β
| β
| β |
| Meta Tags Preview + HTML snippet | β
| β
| β |
| Social Preview Cards (X, Facebook, LinkedIn, WhatsApp) | β
| β
| β |
| Focus Keyword tracking | β
| β
| β |
| Robots Meta (noindex, nofollow, noarchiveβ¦) | β
| β
| β |
| hreflang / multi-language targeting | β
| β
| β |
| Open Graph & Twitter/X card fields | β
| β
| β |
| Additional meta tags | β
| β
| β |
| Frontend integration guides (Next.js, Astro, Vue) | β
| β
| β |
| Readability score | β
| β
| β |
| AI Keyword Suggestions | β | β
| β |
| AI Meta Title & Description generation | β | β
| β |
| SERP Preview (desktop + mobile) | β | β | π |
| Schema.org Wizard (30+ structured data types) | β | β | π |
| Live JSON-LD preview | β | β | π |
| SEO Health Dashboard (site-wide scores) | β | β | π |
| SEO Optimizer β inline bulk edit, type filter, CSV import/export | β | β | π |
| Bulk Open Graph sync | β | β | π |
| Advanced Validation (5 checks + auto-fix) | β | β | π |
| Team Workflow (Draft β Review β Approved) | β | β | π |
| Workflow Dashboard (site-wide status tracking) | β | β | π |
| Duplicate meta title detection | β | β | π |
| AI Bulk SEO Generation | β | β | π |
## What Each Tier Includes
**π Free** β Essential SEO tools built-in. Start optimizing immediately.
**π€ AI** β Add AI-powered suggestions. Choose from OpenAI, Anthropic, or Groq (free tier available).
**π₯ Pro** β Coming Soon. Team workflows, bulk optimization, advanced analytics, and schema management for enterprise teams.
## Table of Contents
- [Quick Start](#quick-start)
- [1. Install the plugin](#1-install-the-plugin)
- [2. Configure in Sanity Studio](#2-configure-in-sanity-studio)
- [3. Add SEO to Your Documents](#3-add-seo-to-your-documents)
- [Configuration Options](#config-options)
- [Framework Integration Guides](#nextjs-integration)
- [Next.js Integration](#nextjs-integration)
- [Astro Integration](#astro-integration)
- [Vue 3 / Nuxt Integration](#vue-3--nuxt-integration)
- [GROQ Fragment & Types](#groq-fragment)
- [Pro Features β Coming Soon](#pro-features--coming-soon)
- [AI Provider Setup](#ai-setup)
- [Upgrading from v1.3](#upgrading-from-v13)
## Quick Start
### 1. Install the plugin
```bash
npm install sanity-plugin-seo
```
**Or with yarn/pnpm:**
```bash
yarn add sanity-plugin-seo # or
pnpm add sanity-plugin-seo
```
**Compatibility:** Sanity Studio v3, v4, and v5
## 2. Configure in Sanity Studio
### Option A: Free features only
```
// sanity.config.ts
import { defineConfig } from "sanity";
import { seoMetaFields } from "sanity-plugin-seo";
export default defineConfig({
plugins: [seoMetaFields()],
});
```
### Option B: With AI (OpenAI, Anthropic, or Groq)
Add AI-powered suggestions for meta titles, descriptions, and keywords.
```
import { defineConfig } from "sanity";
import { seoMetaFields } from "sanity-plugin-seo";
export default defineConfig({
plugins: [
seoMetaFields({
aiFeature: {
provider: "openai", // 'openai' | 'anthropic' | 'groq'
apiKey: process.env.SANITY_STUDIO_OPENAI_KEY!,
model: "gpt-4o-mini", // optional
},
bodyField: "body",
slugField: "slug",
}),
],
});
```
### Option C: With Pro license (Coming Soon)
Unlock team workflows, bulk optimization, and advanced schema management.
```
seoMetaFields({
proFeature: process.env.SANITY_STUDIO_SEO_LICENSE, // Pro features coming soon
});
```
### Complete Configuration
```
import { defineConfig } from "sanity";
import { seoMetaFields } from "sanity-plugin-seo";
export default defineConfig({
plugins: [
seoMetaFields({
// AI provider (OpenAI, Anthropic, or Groq)
aiFeature: {
provider: "openai",
apiKey: process.env.SANITY_STUDIO_OPENAI_KEY!,
model: "gpt-4o-mini",
},
// Content field names in your schema
bodyField: "body",
slugField: "slug",
// Show SEO Health + Optimizer in Studio toolbar (default: true)
dashboard: true,
}),
],
});
```
## 3. Add SEO to Your Documents
Add the `seoMetaFields` type to any document schema in your project:
```
// schemas/page.ts
export default {
name: "page",
type: "document",
fields: [
{ name: "title", type: "string" },
{ name: "slug", type: "slug", options: { source: "title" } },
{ name: "body", type: "array", of: [{ type: "block" }] },
{ name: "seo", type: "seoMetaFields" },
],
};
```
This adds a fully-featured SEO panel with four tabs:
- **Basic SEO** β Meta title, description, keywords
- **Social Sharing** β Open Graph & Twitter cards
- **Advanced** β Robots meta, hreflang, custom tags
- **Schema.org** β 30+ structured data types (Pro)
## Configuration Options
All options are optional. The plugin works great with zero configuration.
| Option | Type | Default | Description |
| -------------------- | ----------------------------------- | ---------------- | ----------------------------------------------------------- |
| **Content Fields** |
| `bodyField` | `string` | `'body'` | Portable Text field for AI analysis and readability scoring |
| `slugField` | `string` | `'slug'` | Slug field for URL preview in SERP |
| **AI Features** |
| `aiFeature` | `object` | β | Enable AI keyword and content suggestions |
| `aiFeature.provider` | `'openai' \| 'anthropic' \| 'groq'` | β | AI provider (OpenAI/Anthropic/Groq) |
| `aiFeature.apiKey` | `string` | β | API key from your provider |
| `aiFeature.model` | `string` | provider default | Model ID (e.g., `gpt-4o-mini`, `claude-haiku-4-5-20251001`) |
| **Pro Features** |
| `proFeature` | `string` | β | Pro license key (coming soon) |
| **UI** |
| `dashboard` | `boolean` | `true` | Show SEO Health & Optimizer in Studio toolbar |
## Framework Integration Guides
Choose your framework and follow the integration guide. All guides include complete examples.
### Next.js Integration
### 1. Sanity client + GROQ fragment
```
npm install @sanity/client
```
```
// lib/sanity.ts
import { createClient } from "@sanity/client";
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || "production",
useCdn: false,
apiVersion: "2024-01-01",
});
export const SEO_GROQ = `seo {
metaTitle, metaDescription, focusKeyword,
nofollowAttributes, robotsMeta, seoKeywords,
seoStatus, seoReviewNotes,
metaImage { asset->{ url } },
openGraph { title, description, siteName, image { asset->{ url } } },
twitter { cardType, site, creator, handle },
hreflang[] { locale, url },
schemaOrg {
schemaType, name, description, url, author,
datePublished, dateModified,
price, priceCurrency, availability,
ratingValue, ratingCount,
startDate, endDate, location,
faqItems[] { question, answer }
}
}`;
```
```
# .env.local
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SITE_URL=https://your-site.com
```
### 2. SEO helpers
```
// app/_seo.ts
import type { Metadata } from "next";
export type SeoField = {
metaTitle?: string;
metaDescription?: string;
nofollowAttributes?: boolean;
robotsMeta?: string[];
seoKeywords?: string[];
seoStatus?: "draft" | "review" | "approved";
seoReviewNotes?: string;
metaImage?: { asset?: { url?: string } };
openGraph?: {
title?: string;
description?: string;
siteName?: string;
image?: { asset?: { url?: string } };
};
twitter?: { cardType?: string; site?: string; creator?: string; handle?: string };
hreflang?: { locale: string; url: string }[];
schemaOrg?: {
schemaType?: string;
name?: string;
description?: string;
url?: string;
author?: string;
datePublished?: string;
dateModified?: string;
faqItems?: { question: string; answer: string }[];
[key: string]: unknown;
};
};
export function buildMetadata(
seo: SeoField | undefined,
fallbackTitle: string | undefined,
slug: string,
): Metadata {
const s = seo ?? {};
const canonical = `${process.env.NEXT_PUBLIC_SITE_URL ?? ""}/${slug}`;
const ogImage = s.openGraph?.image?.asset?.url ?? s.metaImage?.asset?.url;
const robots: string[] = [];
if (s.nofollowAttributes) robots.push("noindex", "nofollow");
s.robotsMeta?.forEach((r) => {
if (!robots.includes(r)) robots.push(r);
});
const languages: Record<string, string> = {};
s.hreflang?.forEach(({ locale, url }) => {
languages[locale] = url;
});
return {
title: s.metaTitle ?? fallbackTitle,
description: s.metaDescription,
...(s.seoKeywords?.length && { keywords: s.seoKeywords.join(", ") }),
...(robots.length && { robots: robots.join(", ") }),
alternates: {
canonical,
...(Object.keys(languages).length && { languages }),
},
openGraph: {
title: s.openGraph?.title ?? s.metaTitle ?? fallbackTitle,
description: s.openGraph?.description ?? s.metaDescription,
url: canonical,
siteName: s.openGraph?.siteName,
...(ogImage && { images: [{ url: ogImage }] }),
},
twitter: {
card: (s.twitter?.cardType as "summary" | "summary_large_image") ?? "summary_large_image",
site: s.twitter?.site,
creator: s.twitter?.creator ?? s.twitter?.handle,
},
};
}
export function buildJsonLd(seo: SeoField | undefined, fallbackTitle?: string): string {
const schema = seo?.schemaOrg;
if (!schema?.schemaType) {
return JSON.stringify({
"@context": "https://schema.org",
"@type": "WebPage",
name: seo?.metaTitle ?? fallbackTitle,
description: seo?.metaDescription,
});
}
if (schema.schemaType === "FAQPage" && schema.faqItems?.length) {
return JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: schema.faqItems.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
});
}
const { schemaType, faqItems, ...rest } = schema;
return JSON.stringify({ "@context": "https://schema.org", "@type": schemaType, ...rest });
}
```
### 3. App Router
> Next.js 15: `params` is a Promise β `await` it first.
```
// app/[slug]/page.tsx
import type { Metadata } from "next";
import { client, SEO_GROQ } from "@/lib/sanity";
import { buildMetadata, buildJsonLd } from "@/app/_seo";
const query = `*[_type == "page" && slug.current == $slug][0]{ title, ${SEO_GROQ} }`;
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const page = await client.fetch(query, { slug });
return buildMetadata(page?.seo, page?.title, slug);
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const page = await client.fetch(query, { slug });
if (!page)
return (
<main>
<p>Page not found.</p>
</main>
);
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: buildJsonLd(page.seo, page.title) }}
/>
<main>
<h1>{page.title}</h1>
</main>
</>
);
}
```
### Pages Router
```
npm install next-seo
```
```
// pages/[slug].tsx
import { NextSeo } from "next-seo";
import { GetStaticProps } from "next";
import { client, SEO_GROQ } from "@/lib/sanity";
import type { SeoField } from "@/app/_seo";
type Props = { page: { title: string; slug: string; seo?: SeoField } };
export default function Page({ page }: Props) {
const seo = page?.seo ?? {};
const canonical = `${process.env.NEXT_PUBLIC_SITE_URL ?? ""}/${page.slug}`;
const ogImage = seo.openGraph?.image?.asset?.url ?? seo.metaImage?.asset?.url;
const schema = seo.schemaOrg;
let jsonLd: string | null = null;
if (schema?.schemaType === "FAQPage" && schema.faqItems?.length) {
jsonLd = JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: schema.faqItems.map((i) => ({
"@type": "Question",
name: i.question,
acceptedAnswer: { "@type": "Answer", text: i.answer },
})),
});
} else if (schema?.schemaType) {
const { schemaType, faqItems, ...rest } = schema;
jsonLd = JSON.stringify({ "@context": "https://schema.org", "@type": schemaType, ...rest });
}
return (
<>
<NextSeo
title={seo.metaTitle}
description={seo.metaDescription}
canonical={canonical}
noindex={!!(seo.nofollowAttributes || seo.robotsMeta?.includes("noindex"))}
nofollow={!!(seo.nofollowAttributes || seo.robotsMeta?.includes("nofollow"))}
additionalMetaTags={
seo.seoKeywords?.length ? [{ name: "keywords", content: seo.seoKeywords.join(", ") }] : []
}
additionalLinkTags={
seo.hreflang?.map(({ locale, url }) => ({
rel: "alternate",
hrefLang: locale,
href: url,
})) ?? []
}
openGraph={{
title: seo.openGraph?.title ?? seo.metaTitle,
description: seo.openGraph?.description ?? seo.metaDescription,
url: canonical,
siteName: seo.openGraph?.siteName,
images: ogImage ? [{ url: ogImage }] : [],
}}
twitter={{
cardType: seo.twitter?.cardType ?? "summary_large_image",
site: seo.twitter?.site,
handle: seo.twitter?.handle ?? seo.twitter?.creator,
}}
/>
{jsonLd && <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd }} />}
<main>
<h1>{page.title}</h1>
</main>
</>
);
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const page = await client.fetch(
`*[_type == "page" && slug.current == $slug][0]{ title, "slug": slug.current, ${SEO_GROQ} }`,
{ slug: params?.slug },
);
return { props: { page }, revalidate: 60 };
};
export async function getStaticPaths() {
const slugs = await client.fetch(`*[_type == "page"].slug.current`);
return { paths: slugs.map((slug: string) => ({ params: { slug } })), fallback: "blocking" };
}
```
### Astro Integration
#### 1. Install + configure
```
npm install @sanity/client astro-seo
```
```
// astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({ output: "server" });
```
```
# .env
PUBLIC_SANITY_PROJECT_ID=your-project-id
PUBLIC_SANITY_DATASET=production
PUBLIC_SITE_URL=https://your-site.com
```
### 2. Sanity client
```
// src/lib/sanity.ts
import { createClient } from "@sanity/client";
export const client = createClient({
projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID,
dataset: import.meta.env.PUBLIC_SANITY_DATASET ?? "production",
useCdn: false,
apiVersion: "2024-01-01",
});
export const SEO_GROQ = `seo {
metaTitle, metaDescription, focusKeyword,
nofollowAttributes, robotsMeta, seoKeywords,
seoStatus, seoReviewNotes,
metaImage { asset->{ url } },
openGraph { title, description, siteName, image { asset->{ url } } },
twitter { cardType, site, creator, handle },
hreflang[] { locale, url },
schemaOrg {
schemaType, name, description, url, author,
datePublished, dateModified,
price, priceCurrency, availability,
ratingValue, ratingCount,
startDate, endDate, location,
faqItems[] { question, answer }
}
}`;
export type SeoField = {
metaTitle?: string;
metaDescription?: string;
nofollowAttributes?: boolean;
robotsMeta?: string[];
seoKeywords?: string[];
seoStatus?: string;
seoReviewNotes?: string;
metaImage?: { asset?: { url?: string } };
openGraph?: {
title?: string;
description?: string;
siteName?: string;
image?: { asset?: { url?: string } };
};
twitter?: { cardType?: string; site?: string; creator?: string; handle?: string };
hreflang?: { locale: string; url: string }[];
schemaOrg?: {
schemaType?: string;
faqItems?: { question: string; answer: string }[];
[key: string]: unknown;
};
};
export function buildJsonLd(
schema: SeoField["schemaOrg"],
fallbackTitle?: string,
fallbackDesc?: string,
): string | null {
if (!schema?.schemaType) {
return JSON.stringify({
"@context": "https://schema.org",
"@type": "WebPage",
name: fallbackTitle,
description: fallbackDesc,
});
}
if (schema.schemaType === "FAQPage" && schema.faqItems?.length) {
return JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: schema.faqItems.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
});
}
const { schemaType, faqItems, ...rest } = schema;
return JSON.stringify({ "@context": "https://schema.org", "@type": schemaType, ...rest });
}
```
### 3. Page route
```
// src/pages/[slug].astro
import { SEO } from 'astro-seo'
import { client, SEO_GROQ, buildJsonLd } from '../lib/sanity'
export const prerender = false
const { slug } = Astro.params
const page = await client.fetch(
`*[_type == "page" && slug.current == $slug][0]{ title, description, ${SEO_GROQ} }`,
{ slug },
)
if (!page) return Astro.redirect('/404')
const seo = page.seo ?? {}
const pageUrl = `${import.meta.env.PUBLIC_SITE_URL ?? ''}/${slug}`
const title = seo.metaTitle ?? page.title
const description = seo.metaDescription ?? page.description ?? ''
const ogImage = seo.openGraph?.image?.asset?.url ?? seo.metaImage?.asset?.url
const jsonLd = buildJsonLd(seo.schemaOrg, title, description)
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<SEO
title={title}
description={description}
canonical={pageUrl}
noindex={seo.robotsMeta?.includes('noindex') ?? false}
nofollow={seo.nofollowAttributes ?? false}
openGraph={{
basic: { title: seo.openGraph?.title ?? title, type: 'website', image: ogImage ?? '', url: pageUrl },
optional: { description: seo.openGraph?.description ?? description, siteName: seo.openGraph?.siteName },
}}
twitter={{
card: (seo.twitter?.cardType ?? 'summary_large_image') as any,
site: seo.twitter?.site,
creator: seo.twitter?.creator ?? seo.twitter?.handle,
}}
extend={{
meta: [
{ name: 'robots', content: seo.robotsMeta?.join(', ') ?? 'index,follow' },
...(seo.seoKeywords?.length ? [{ name: 'keywords', content: seo.seoKeywords.join(', ') }] : []),
],
link: seo.hreflang?.map((h: { locale: string; url: string }) => ({ rel: 'alternate', hreflang: h.locale, href: h.url })) ?? [],
}}
/>
{jsonLd && <script type="application/ld+json" set:html={jsonLd} />}
</head>
<body>
<main><h1>{page.title}</h1></main>
</body>
</html>
```
<details>
<summary>Without astro-seo (native head tags)</summary>
```
import { client, SEO_GROQ, buildJsonLd } from '../lib/sanity'
export const prerender = false
const { slug } = Astro.params
const page = await client.fetch(
`*[_type == "page" && slug.current == $slug][0]{ title, description, ${SEO_GROQ} }`,
{ slug },
)
if (!page) return Astro.redirect('/404')
const seo = page.seo ?? {}
const pageUrl = `${import.meta.env.PUBLIC_SITE_URL ?? ''}/${slug}`
const title = seo.metaTitle ?? page.title
const description = seo.metaDescription ?? page.description ?? ''
const ogImage = seo.openGraph?.image?.asset?.url ?? seo.metaImage?.asset?.url
const jsonLd = buildJsonLd(seo.schemaOrg, title, description)
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={pageUrl} />
<meta name="robots" content={seo.robotsMeta?.join(', ') ?? 'index,follow'} />
{seo.seoKeywords?.length && <meta name="keywords" content={seo.seoKeywords.join(', ')} />}
<meta property="og:title" content={seo.openGraph?.title ?? title} />
<meta property="og:description" content={seo.openGraph?.description ?? description} />
<meta property="og:url" content={pageUrl} />
<meta property="og:type" content="website" />
{ogImage && <meta property="og:image" content={ogImage} />}
{seo.openGraph?.siteName && <meta property="og:site_name" content={seo.openGraph.siteName} />}
<meta name="twitter:card" content={seo.twitter?.cardType ?? 'summary_large_image'} />
{seo.twitter?.site && <meta name="twitter:site" content={seo.twitter.site} />}
{(seo.twitter?.creator ?? seo.twitter?.handle) && (
<meta name="twitter:creator" content={seo.twitter.creator ?? seo.twitter.handle} />
)}
{seo.hreflang?.map((h: { locale: string; url: string }) => (
<link rel="alternate" hreflang={h.locale} href={h.url} />
))}
{jsonLd && <script type="application/ld+json" set:html={jsonLd} />}
</head>
<body>
<main><h1>{page.title}</h1></main>
</body>
</html>
```
</details>
### Vue 3 / Nuxt Integration
> Don't use `@nuxtjs/sanity` β it pulls in React packages and breaks hydration. Use `@sanity/client` directly.
### Nuxt 3
```
npm install @sanity/client
```
```
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
sanityProjectId: process.env.NUXT_PUBLIC_SANITY_PROJECT_ID ?? "",
sanityDataset: process.env.NUXT_PUBLIC_SANITY_DATASET ?? "production",
siteUrl: process.env.NUXT_PUBLIC_SITE_URL ?? "",
},
},
});
```
```
# .env
NUXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NUXT_PUBLIC_SANITY_DATASET=production
NUXT_PUBLIC_SITE_URL=https://your-site.com
```
> If you get CORS errors after client-side navigation, add `http://localhost:3000` to your project's CORS settings at [sanity.io/manage](https://sanity.io/manage).
**`composables/useSanityFetch.ts`**
```
import { createClient } from "@sanity/client";
let _client: ReturnType<typeof createClient> | null = null;
function getSanityClient() {
if (_client) return _client;
const config = useRuntimeConfig();
_client = createClient({
projectId: config.public.sanityProjectId,
dataset: config.public.sanityDataset,
useCdn: false,
apiVersion: "2024-01-01",
});
return _client;
}
export function useSanityFetch<T>(query: string, params?: Record<string, unknown>) {
const key = query + (params ? JSON.stringify(params) : "");
return useAsyncData<T>(key, () => getSanityClient().fetch<T>(query, params ?? {}));
}
```
**`composables/useSeo.ts`**
```
export type SeoField = {
metaTitle?: string;
metaDescription?: string;
nofollowAttributes?: boolean;
robotsMeta?: string[];
seoKeywords?: string[];
seoStatus?: string;
seoReviewNotes?: string;
metaImage?: { asset?: { url?: string } };
openGraph?: {
title?: string;
description?: string;
siteName?: string;
image?: { asset?: { url?: string } };
};
twitter?: { cardType?: string; site?: string; creator?: string; handle?: string };
hreflang?: { locale: string; url: string }[];
schemaOrg?: {
schemaType?: string;
name?: string;
description?: string;
url?: string;
author?: string;
datePublished?: string;
dateModified?: string;
faqItems?: { question: string; answer: string }[];
[key: string]: unknown;
};
};
export const SEO_GROQ = `seo {
metaTitle, metaDescription, focusKeyword,
nofollowAttributes, robotsMeta, seoKeywords,
seoStatus, seoReviewNotes,
metaImage { asset->{ url } },
openGraph { title, description, siteName, image { asset->{ url } } },
twitter { cardType, site, creator, handle },
hreflang[] { locale, url },
schemaOrg {
schemaType, name, description, url, author,
datePublished, dateModified,
price, priceCurrency, availability,
ratingValue, ratingCount,
startDate, endDate, location,
faqItems[] { question, answer }
}
}`;
export function buildJsonLd(
schema: SeoField["schemaOrg"],
fallbackTitle?: string,
fallbackDesc?: string,
): string | null {
if (!schema?.schemaType) {
return JSON.stringify({
"@context": "https://schema.org",
"@type": "WebPage",
name: fallbackTitle,
description: fallbackDesc,
});
}
if (schema.schemaType === "FAQPage" && schema.faqItems?.length) {
return JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: schema.faqItems.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
});
}
const { schemaType, faqItems, ...rest } = schema;
return JSON.stringify({ "@context": "https://schema.org", "@type": schemaType, ...rest });
}
```
**`pages/[slug].vue`**
```
<script setup lang="ts">
import { SEO_GROQ, buildJsonLd, type SeoField } from "~/composables/useSeo";
const route = useRoute();
const slug = route.params.slug as string;
const siteUrl = useRuntimeConfig().public.siteUrl;
type Page = { title: string; slug: string; description?: string; seo?: SeoField };
const { data: page } = await useSanityFetch<Page | null>(
`*[_type == "page" && slug.current == $slug][0]{ title, "slug": slug.current, description, ${SEO_GROQ} }`,
{ slug },
);
if (!page.value) throw createError({ statusCode: 404, statusMessage: "Page not found" });
const seo = computed(() => page.value?.seo);
const pageUrl = `${siteUrl}/${slug}`;
const title = computed(() => seo.value?.metaTitle ?? page.value?.title ?? "");
const description = computed(() => seo.value?.metaDescription ?? page.value?.description ?? "");
const ogImage = computed(
() => seo.value?.openGraph?.image?.asset?.url ?? seo.value?.metaImage?.asset?.url,
);
const jsonLd = computed(() => buildJsonLd(seo.value?.schemaOrg, title.value, description.value));
useHead({
title: title.value,
meta: [
{ name: "description", content: description.value },
{ name: "robots", content: seo.value?.robotsMeta?.join(", ") ?? "index,follow" },
...(seo.value?.seoKeywords?.length
? [{ name: "keywords", content: seo.value.seoKeywords.join(", ") }]
: []),
{ property: "og:title", content: seo.value?.openGraph?.title ?? title.value },
{ property: "og:description", content: seo.value?.openGraph?.description ?? description.value },
{ property: "og:type", content: "article" },
{ property: "og:url", content: pageUrl },
...(ogImage.value ? [{ property: "og:image", content: ogImage.value }] : []),
...(seo.value?.openGraph?.siteName
? [{ property: "og:site_name", content: seo.value.openGraph.siteName }]
: []),
{ name: "twitter:card", content: seo.value?.twitter?.cardType ?? "summary_large_image" },
...(seo.value?.twitter?.site
? [{ name: "twitter:site", content: seo.value.twitter.site }]
: []),
...(seo.value?.twitter?.creator ?? seo.value?.twitter?.handle
? [
{
name: "twitter:creator",
content: seo.value?.twitter?.creator ?? seo.value?.twitter?.handle ?? "",
},
]
: []),
],
link: [
{ rel: "canonical", href: pageUrl },
...(seo.value?.hreflang?.map(({ locale, url }) => ({
rel: "alternate",
hreflang: locale,
href: url,
})) ?? []),
],
script: jsonLd.value ? [{ type: "application/ld+json", innerHTML: jsonLd.value }] : [],
});
</script>
<template>
<main>
<h1>{{ page?.title }}</h1>
</main>
</template>
```
### Vue 3 standalone (Vite + @unhead/vue)
```
npm install @sanity/client @unhead/vue
```
```
// src/composables/useSeo.ts
import { createClient } from "@sanity/client";
import { useHead } from "@unhead/vue";
const client = createClient({
projectId: import.meta.env.VITE_SANITY_PROJECT_ID,
dataset: import.meta.env.VITE_SANITY_DATASET ?? "production",
useCdn: false,
apiVersion: "2024-01-01",
});
type SeoField = {
metaTitle?: string;
metaDescription?: string;
nofollowAttributes?: boolean;
robotsMeta?: string[];
seoKeywords?: string[];
metaImage?: { asset?: { url?: string } };
openGraph?: {
title?: string;
description?: string;
siteName?: string;
image?: { asset?: { url?: string } };
};
twitter?: { cardType?: string; site?: string; creator?: string; handle?: string };
hreflang?: { locale: string; url: string }[];
schemaOrg?: {
schemaType?: string;
faqItems?: { question: string; answer: string }[];
[key: string]: unknown;
};
};
export async function useSanityPage(slug: string) {
const page = await client.fetch<{ title: string; seo?: SeoField } | null>(
`*[_type == "page" && slug.current == $slug][0]{
title,
seo {
metaTitle, metaDescription, nofollowAttributes, robotsMeta, seoKeywords,
metaImage { asset->{ url } },
openGraph { title, description, siteName, image { asset->{ url } } },
twitter { cardType, site, creator, handle },
hreflang[] { locale, url },
schemaOrg { schemaType, name, description, url, faqItems[] { question, answer } }
}
}`,
{ slug },
);
const seo = page?.seo ?? {};
const canonical = `${import.meta.env.VITE_SITE_URL ?? ""}/${slug}`;
const ogImage = seo.openGraph?.image?.asset?.url ?? seo.metaImage?.asset?.url;
let jsonLd: string | null = null;
const schema = seo.schemaOrg;
if (schema?.schemaType === "FAQPage" && schema.faqItems?.length) {
jsonLd = JSON.stringify({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: schema.faqItems.map((i) => ({
"@type": "Question",
name: i.question,
acceptedAnswer: { "@type": "Answer", text: i.answer },
})),
});
} else if (schema?.schemaType) {
const { schemaType, faqItems, ...rest } = schema;
jsonLd = JSON.stringify({ "@context": "https://schema.org", "@type": schemaType, ...rest });
}
useHead({
title: seo.metaTitle,
meta: [
{ name: "description", content: seo.metaDescription },
{ name: "robots", content: seo.robotsMeta?.join(", ") ?? "index,follow" },
...(seo.seoKeywords?.length
? [{ name: "keywords", content: seo.seoKeywords.join(", ") }]
: []),
{ property: "og:title", content: seo.openGraph?.title ?? seo.metaTitle },
{ property: "og:description", content: seo.openGraph?.description ?? seo.metaDescription },
{ property: "og:url", content: canonical },
...(seo.openGraph?.siteName
? [{ property: "og:site_name", content: seo.openGraph.siteName }]
: []),
...(ogImage ? [{ property: "og:image", content: ogImage }] : []),
{ name: "twitter:card", content: seo.twitter?.cardType ?? "summary_large_image" },
...(seo.twitter?.site ? [{ name: "twitter:site", content: seo.twitter.site }] : []),
...(seo.twitter?.creator ?? seo.twitter?.handle
? [{ name: "twitter:creator", content: seo.twitter.creator ?? seo.twitter.handle }]
: []),
],
link: [
{ rel: "canonical", href: canonical },
...(seo.hreflang?.map(({ locale, url }) => ({
rel: "alternate",
hreflang: locale,
href: url,
})) ?? []),
],
script: jsonLd ? [{ type: "application/ld+json", innerHTML: jsonLd }] : [],
});
return { page };
}
```
## GROQ Fragment & Types
Copy and use this GROQ fragment to fetch all SEO fields from your documents:
```
const pageQuery = groq`*[_type == "page" && slug.current == $slug][0]{
title,
seo {
metaTitle, metaDescription, focusKeyword, seoKeywords,
nofollowAttributes, robotsMeta,
metaImage { asset->{ url } },
openGraph { title, description, siteName, image { asset->{ url } } },
twitter { cardType, site, creator, handle },
hreflang[] { locale, url },
schemaOrg {
schemaType, name, description, url, author,
datePublished, dateModified,
price, priceCurrency, availability,
ratingValue, ratingCount,
startDate, endDate, location,
faqItems[] { question, answer }
},
seoStatus, seoReviewNotes
}
}`;
```
| Field | Type | Notes |
| --------------------------- | ---------- | --------------------------------- |
| `metaTitle` | `string` | Page title for search engines |
| `metaDescription` | `string` | Page description |
| `focusKeyword` | `string` | Primary keyword |
| `seoKeywords` | `string[]` | Additional keywords |
| `nofollowAttributes` | `boolean` | Noindex toggle |
| `robotsMeta` | `string[]` | e.g. `['noindex', 'nofollow']` |
| `metaImage.asset.url` | `string` | Fallback OG/Twitter image |
| `openGraph.title` | `string` | OG title |
| `openGraph.description` | `string` | OG description |
| `openGraph.siteName` | `string` | OG site name |
| `openGraph.image.asset.url` | `string` | OG image |
| `twitter.cardType` | `string` | e.g. `summary_large_image` |
| `twitter.site` | `string` | Twitter @account |
| `twitter.creator` | `string` | Twitter @author |
| `hreflang[].locale` | `string` | BCP 47 locale code |
| `hreflang[].url` | `string` | Alternate URL for that locale |
| `schemaOrg.schemaType` | `string` | Schema.org type |
| `seoStatus` | `string` | `draft` \| `review` \| `approved` |
| `seoReviewNotes` | `string` | Reviewer notes |
## Free Features
### Readability Score
Calculates a Flesch-Kincaid Grade Level for your content and shows it with color-coded feedback directly beneath the body field.
| Grade | Meaning |
| ----- | ------------------------------------------ |
| 1β6 | Very easy β general public |
| 7β8 | Easy β ideal for most blog posts |
| 9β12 | Average β acceptable for technical content |
| 13+ | Difficult β academic/specialist |
Green = Grade β€ 8, Amber = 9β12, Red = 13+.
## Pro Features (Coming Soon)
Advanced team workflows, bulk optimization, and analytics for enterprise teams. All features are in development and launching soon.
> **Get notified when Pro launches:** Star the [GitHub repo](https://github.com/bhargav-bkpatel/sanity-plugin-seo) or watch the [npm package](https://www.npmjs.com/package/sanity-plugin-seo)
### SERP Preview
Desktop and mobile Google search mockup with pixel-accurate title/description truncation.
### Schema.org Wizard
Guided form for 30 structured data types. Fills out only the fields relevant to the selected type and shows a live JSON-LD preview as you type.
Supported types: Article, Blog Post, Product, FAQ Page, Local Business, Event, Organization, Web Page, Video, Recipe, Person, Course, Job Posting, Breadcrumb, News Article, How-To Guide, Review, Software/App, Book, Movie, Service, Professional Service, Medical Condition, Dataset, Podcast, Podcast Episode, Tourist Attraction, Accommodation, Sports Team, Collection Page, About Page.
### Advanced Validation
5 checks with one-click fixes where possible:
| Check | Auto-fixable |
| --------------------------------------- | :---------------------: |
| Unique meta title (GROQ query) | β |
| Open Graph image present | β |
| Open Graph title set | β
Copy from meta title |
| Focus keyword in meta title | β |
| Meta description length (100β160 chars) | β |
Progress bar + "Fix all" button. Each auto-fix shows a 2.5-second inline confirmation.
### Team Workflow
Three-status pipeline: **Draft β Needs SEO Review β SEO Approved**
- Click any step to jump to it
- Quick-action buttons (Request Review / Mark Approved / Reset to Draft)
- Review Notes field for feedback between team members
- `seoStatus` and `seoReviewNotes` stored on the document, queryable via GROQ
### SEO Health Dashboard
Site-wide score overview for all documents.
- Stat cards with average score, issue counts, pages missing OG
- Filter by score range (Poor / Needs Work / Good) or issue type
- Paginated list with score bar, type badge, and last-updated date
- Click any row to open the document
### SEO Optimizer
Fix queue for all documents with SEO issues.
- Inline editing β expand a row to edit meta title, description, focus keyword, OG title, OG description
- Type filter dropdown
- **Sync Open Graph** bulk action β copies meta title/description to OG fields for selected pages
- **Import/Export CSV** β edit in a spreadsheet and re-import
- Inline result log after every bulk operation
### Workflow Dashboard
Top-level tool that shows every document's review status in one place.
- Stat cards (All / Draft / Needs Review / Approved) β click to filter
- Inline status actions per row β no need to open the document
- Expand a row to see SEO issues and add review notes
- Direct link to open any document in the editor
### AI Bulk SEO Generation β Coming Soon
Generate meta titles and descriptions for every page using your configured AI provider. Pick a target field in the SEO Optimizer, select pages, and let it run.
## Pro License Setup β Coming Soon
> Not available yet. Watch the [npm package](https://www.npmjs.com/package/sanity-plugin-seo) for launch.
When it launches:
1. Purchase a license (link shared on launch)
2. Add the key to your env file:
```
SANITY_STUDIO_SEO_LICENSE=your-license-key-here
```
3. Pass it to the plugin:
```
seoMetaFields({
proFeature: process.env.SANITY_STUDIO_SEO_LICENSE,
});
```
> Sanity Studio env vars must be prefixed with `SANITY_STUDIO_` to be included in the browser bundle.
## AI Provider Setup
Three AI providers supported. Choose based on your needs and budget.
### OpenAI (Paid)
```
seoMetaFields({
aiFeature: {
provider: "openai",
apiKey: process.env.SANITY_STUDIO_OPENAI_KEY!,
model: "gpt-4o-mini",
},
bodyField: "body",
});
```
Recommended: `gpt-4o-mini` (fast), `gpt-4o` (better quality)
### Anthropic (Paid)
```
seoMetaFields({
aiFeature: {
provider: "anthropic",
apiKey: process.env.SANITY_STUDIO_ANTHROPIC_KEY!,
model: "claude-haiku-4-5-20251001",
},
bodyField: "body",
});
```
**Recommended models:** `claude-haiku-4-5-20251001` (fast & cheap), `claude-sonnet-4-6` (best quality)
### Groq (Free)
```
seoMetaFields({
aiFeature: {
provider: "groq",
apiKey: process.env.SANITY_STUDIO_GROQ_KEY!,
model: "llama-3.3-70b-versatile",
},
bodyField: "body",
});
```
**Free API:** Sign up at [console.groq.com](https://console.groq.com)
**Recommended models:** `llama-3.3-70b-versatile`, `mixtral-8x7b-32768`
> β οΈ **Security Note:** API keys are bundled in the browser. Always use restricted API keys with minimal permissions, and prefix env vars with `SANITY_STUDIO_`.
## Upgrading from v1.3 to v1.4
**Good news:** No schema migrations needed. All existing SEO fields continue to work. Just update your config.
### What Changed
The basic setup works exactly the same:
```
// v1.3 and v1.4 β no changes needed
plugins: [seoMetaFields()];
```
**New in v1.4:** AI and Pro features are now available with renamed config keys:
| Feature | Config Key (v1.4) |
| -------------- | ----------------- |
| AI suggestions | `aiFeature` |
| Pro license | `proFeature` |
See [AI Provider Setup](#ai-provider-setup) and [Pro License Setup](#pro-license-setup--coming-soon) for configuration examples.
### New Fields in v1.4
These new schema fields are optional and automatically included:
| Field | Type | Purpose |
| -------------------- | ---------- | ---------------------------------------------------------- |
| `focusKeyword` | `string` | Primary target keyword for tracking |
| `robotsMeta` | `string[]` | Indexing control (noindex, nofollow, noarchive, nosnippet) |
| `hreflang` | `array` | Alternate language/regional URLs |
| `additionalMetaTags` | `array` | Custom name/content meta tag pairs |
| `schemaOrg` | `object` | Schema.org structured data (Pro feature) |
| `seoStatus` | `string` | Workflow status: draft, review, approved (Pro feature) |
| `seoReviewNotes` | `string` | Reviewer feedback and notes (Pro feature) |
Existing documents work fine without these new fields β they're completely optional.
## Free Features Deep Dive
Everything you need to optimize your content for search engines without leaving Sanity Studio.
- **Live SEO Score** β Real-time scoring (0-100) with detailed breakdown of what's missing or needs improvement
- **GEO Checklist** β Validates content structure for Google's AI Overview eligibility
- **Meta Tags Preview** β See exactly how your page appears in Google search results
- **Social Preview Cards** β X, Facebook, LinkedIn, and WhatsApp preview rendering
- **Readability Score** β Flesch-Kincaid grade level analysis with color-coded feedback
- **Robots Meta Control** β Set noindex, nofollow, noarchive, and nosnippet flags
- **hreflang / Multi-language** β Target alternate versions for different locales
- **Additional Meta Tags** β Add custom meta tags for any use case
- **Frontend Integration Guides** β Ready-to-use code for Next.js, Astro, Vue, and Nuxt
## TypeScript Types
```
interface SeoData {
metaTitle?: string;
metaDescription?: string;
focusKeyword?: string;
seoKeywords?: string[];
nofollowAttributes?: boolean;
robotsMeta?: string[];
metaImage?: { asset: { url: string } };
openGraph?: {
title?: string;
description?: string;
siteName?: string;
image?: { asset: { url: string } };
};
twitter?: {
cardType?: string;
site?: string;
creator?: string;
handle?: string;
};
hreflang?: { locale: string; url: string }[];
schemaOrg?: {
schemaType?: string;
name?: string;
description?: string;
url?: string;
author?: string;
datePublished?: string;
dateModified?: string;
price?: string;
priceCurrency?: string;
availability?: string;
ratingValue?: string;
ratingCount?: string;
startDate?: string;
endDate?: string;
location?: string;
faqItems?: { question: string; answer: string }[];
};
seoStatus?: "draft" | "review" | "approved";
seoReviewNotes?: string;
}
```
## Support & Community
- **Bug Reports:** [GitHub Issues](https://github.com/bhargav-bkpatel/sanity-plugin-seo/issues)
- **NPM Package:** [sanity-plugin-seo](https://www.npmjs.com/package/sanity-plugin-seo)
## Creator
Built by [Bhargav Patel](https://bkpatel.com/)
## License
MIT β Free and AI features are open source. Pro coming soon.