Sanity Seo Plugin
The sanity-plugin-seo Plugin is designed to simplify the process of generating SEO fields for various types of content.
Install command
npm i sanity-plugin-seo
β‘ Sanity Plugin SEO
Sanity Studio Compatibility:
Framework Support:
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
- Configuration Options
- Framework Integration Guides
- GROQ Fragment & Types
- Pro Features β Coming Soon
- AI Provider Setup
- Upgrading from v1.3
Quick Start
1. Install the plugin
npm install sanity-plugin-seoOr with yarn/pnpm:
yarn add sanity-plugin-seo # or
pnpm add sanity-plugin-seoCompatibility: 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.com2. 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:
paramsis a Promise βawaitit 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.com2. 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>Without astro-seo (native head tags)
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>Vue 3 / Nuxt Integration
Don't use
@nuxtjs/sanityβ it pulls in React packages and breaks hydration. Use@sanity/clientdirectly.
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.comIf you get CORS errors after client-side navigation, add
http://localhost:3000to your project's CORS settings at 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 or watch the npm package
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
seoStatusandseoReviewNotesstored 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 for launch.
When it launches:
- Purchase a license (link shared on launch)
- Add the key to your env file:
SANITY_STUDIO_SEO_LICENSE=your-license-key-here- 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
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 and Pro License Setup 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
- NPM Package: sanity-plugin-seo
Creator
Built by Bhargav Patel
License
MIT β Free and AI features are open source. Pro coming soon.