8.3 KiB
title, description
| title | description |
|---|---|
| Sanity SEO Best Practices | SEO best practices for Sanity with Next.js, including metadata, Open Graph, sitemaps, redirects, and JSON-LD structured data. |
Sanity SEO Best Practices
1. Core Philosophy
SEO doesn't require complex configurations. A few core principles, applied consistently:
- Smart defaults with optional overrides — Don't require SEO fields; use existing content as fallback
- Use GROQ for fallback logic — Move conditional logic into queries, not components
- Leverage Next.js APIs — Use
generateMetadata,sitemap.ts, not manual<meta>tags - Structured content = structured data — Your content model is already SEO-ready
2. SEO Schema Type (Reusable)
Create a reusable SEO object type for consistent metadata across document types.
// schemaTypes/seoType.ts
import { defineField, defineType } from "sanity";
export const seoType = defineType({
name: "seo",
title: "SEO",
type: "object",
fields: [
defineField({
name: "title",
description: "Overrides the page title if provided",
type: "string",
}),
defineField({
name: "description",
type: "text",
rows: 3,
}),
defineField({
name: "image",
description: "Image for social sharing (1200x630 recommended)",
type: "image",
options: { hotspot: true },
}),
defineField({
name: "noIndex",
description: "Hide this page from search engines",
type: "boolean",
initialValue: false,
}),
],
});
Usage in document types:
defineField({
name: "seo",
type: "seo",
})
3. GROQ Queries with Fallbacks
Use coalesce() to provide fallback values. This keeps frontend logic clean.
*[_type == "page" && slug.current == $slug][0]{
...,
"seo": {
// Use SEO field if provided, otherwise fall back to main title
"title": coalesce(seo.title, title, ""),
"description": coalesce(seo.description, ""),
"image": seo.image,
"noIndex": seo.noIndex == true
}
}
Key principle: seo.title will never be null — it contains either the SEO override, the page title, or empty string.
4. Next.js Metadata (The Right Way)
Use generateMetadata — never render <title> or <meta> tags directly in components.
// app/(frontend)/[slug]/page.tsx
import type { Metadata } from "next";
import { urlFor } from "@/sanity/lib/image";
type RouteProps = {
params: Promise<{ slug: string }>;
};
// Extract fetch to reuse in both functions
const getPage = async (params: RouteProps["params"]) =>
sanityFetch({
query: PAGE_QUERY,
params: await params,
stega: false, // Critical for SEO!
});
export async function generateMetadata({ params }: RouteProps): Promise<Metadata> {
const { data: page } = await getPage(params);
if (!page) return {};
const metadata: Metadata = {
title: page.seo.title,
description: page.seo.description,
};
// Open Graph image
if (page.seo.image) {
metadata.openGraph = {
images: {
url: urlFor(page.seo.image).width(1200).height(630).url(),
width: 1200,
height: 630,
},
};
}
// noIndex robots directive
if (page.seo.noIndex) {
metadata.robots = "noindex";
}
return metadata;
}
export default async function Page({ params }: RouteProps) {
const { data: page } = await getPage(params);
// ... render page
}
Critical: Always set stega: false when fetching for metadata. Stega characters in <title> destroy SEO.
5. Dynamic Sitemap
Use Next.js sitemap.ts convention to auto-generate from Sanity content.
GROQ Query
*[_type in ["page", "post"] && defined(slug.current) && seo.noIndex != true] {
"href": select(
_type == "page" => "/" + slug.current,
_type == "post" => "/posts/" + slug.current,
slug.current
),
_updatedAt
}
Route Implementation
// app/sitemap.ts
import { MetadataRoute } from "next";
import { client } from "@/sanity/lib/client";
import { SITEMAP_QUERY } from "@/sanity/lib/queries";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000";
try {
const paths = await client.fetch(SITEMAP_QUERY);
if (!paths) return [];
return paths.map((path) => ({
url: new URL(path.href!, baseUrl).toString(),
lastModified: new Date(path._updatedAt),
changeFrequency: "weekly",
priority: 1,
}));
} catch (error) {
console.error("Sitemap generation failed:", error);
return [];
}
}
Note: Sitemap limit is 50,000 URLs per file. For larger sites, use sitemap index.
6. Redirects (Managed in Sanity)
Create a redirect document type for content team management.
Schema
// schemaTypes/redirectType.ts
import { defineField, defineType, SanityDocumentLike } from "sanity";
import { LinkIcon } from "@sanity/icons";
function isValidPath(value: string | undefined) {
if (!value) return "Required";
if (!value.startsWith("/")) return "Must start with /";
if (/[^a-zA-Z0-9\-_/:]/.test(value)) return "Invalid characters";
return true;
}
export const redirectType = defineType({
name: "redirect",
title: "Redirect",
type: "document",
icon: LinkIcon,
validation: (Rule) =>
Rule.custom((doc: SanityDocumentLike | undefined) => {
if (doc?.source === doc?.destination) {
return "Source and destination cannot be the same";
}
return true;
}),
fields: [
defineField({
name: "source",
type: "string",
validation: (Rule) => Rule.required().custom(isValidPath),
}),
defineField({
name: "destination",
type: "string",
validation: (Rule) => Rule.required(),
}),
defineField({
name: "permanent",
description: "301 (permanent) or 302 (temporary)",
type: "boolean",
initialValue: true,
}),
defineField({
name: "isEnabled",
type: "boolean",
initialValue: true,
}),
],
});
Next.js Config
// next.config.ts
import { fetchRedirects } from "@/sanity/lib/fetchRedirects";
const nextConfig: NextConfig = {
async redirects() {
return await fetchRedirects();
},
};
Limits: Vercel allows max 1,024 redirects in next.config. For more, use middleware.
7. Dynamic Open Graph Images
Generate OG images on-the-fly using Next.js Edge Runtime at /api/og.
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(request: Request) {
const id = new URL(request.url).searchParams.get("id");
if (!id) return new Response("Missing id", { status: 400 });
const data = await client.fetch(`*[_id == $id][0]{ title }`, { id });
return new ImageResponse(
<div tw="flex w-full h-full bg-blue-500 text-white p-10">
<h1 tw="text-6xl font-bold">{data?.title || "Untitled"}</h1>
</div>,
{ width: 1200, height: 630 }
);
}
Use as fallback in metadata: url: page.seo.image ? urlFor(page.seo.image).url() : \/api/og?id=${page._id}``
8. JSON-LD Structured Data
Use schema-dts for type-safe structured data.
npm install schema-dts
FAQ Example
import { FAQPage, WithContext } from "schema-dts";
const generateFaqData = (faqs: FAQ[]): WithContext<FAQPage> => ({
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map((faq) => ({
"@type": "Question",
name: faq.title,
acceptedAnswer: {
"@type": "Answer",
text: faq.text, // Use pt::text() in GROQ to get plain text
},
})),
});
// In component
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(generateFaqData(faqs)) }}
/>
GROQ for Plain Text
faqs[]->{
_id,
title,
body,
"text": pt::text(body) // Convert Portable Text to plain string
}
9. Testing Tools
- Open Graph: opengraph.ing
- Facebook: Sharing Debugger
- Twitter: Card Validator
- LinkedIn: Post Inspector
- Sitemap: XML Sitemaps Validator