This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
---
|
||||
title: Sanity SEO Best Practices
|
||||
description: 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.
|
||||
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
defineField({
|
||||
name: "seo",
|
||||
type: "seo",
|
||||
})
|
||||
```
|
||||
|
||||
## 3. GROQ Queries with Fallbacks
|
||||
|
||||
Use `coalesce()` to provide fallback values. This keeps frontend logic clean.
|
||||
|
||||
```groq
|
||||
*[_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.
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
```groq
|
||||
*[_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
|
||||
```typescript
|
||||
// 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
|
||||
```typescript
|
||||
// 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
|
||||
```typescript
|
||||
// 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`.
|
||||
|
||||
```typescript
|
||||
// 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.
|
||||
|
||||
```bash
|
||||
npm install schema-dts
|
||||
```
|
||||
|
||||
### FAQ Example
|
||||
```typescript
|
||||
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
|
||||
```groq
|
||||
faqs[]->{
|
||||
_id,
|
||||
title,
|
||||
body,
|
||||
"text": pt::text(body) // Convert Portable Text to plain string
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Testing Tools
|
||||
|
||||
- **Open Graph:** [opengraph.ing](https://opengraph.ing/)
|
||||
- **Facebook:** [Sharing Debugger](https://developers.facebook.com/tools/debug/)
|
||||
- **Twitter:** [Card Validator](https://cards-dev.twitter.com/validator)
|
||||
- **LinkedIn:** [Post Inspector](https://www.linkedin.com/post-inspector/)
|
||||
- **Sitemap:** [XML Sitemaps Validator](https://www.xml-sitemaps.com/validate-xml-sitemap.html)
|
||||
Reference in New Issue
Block a user