Frontend Integration
Query SEO data via GROQ and render meta tags in your frontend framework. Next.js can use the built-in metadata helper. Astro, Nuxt, Vue, and SvelteKit can use the framework-neutral head helper.
Built-in helpers
Use buildSeoMeta() from sanity-plugin-seofields/next for Next.js App Router. Use buildSeoHead() from sanity-plugin-seofields/head for Astro, Nuxt, Vue, SvelteKit, Remix, or any frontend that wants plain head tag data.
Next.js App Router
Return buildSeoMeta() from generateMetadata().
React head renderers
Render <SeoMetaTags> inside a React <Head>.
Other frameworks
Map buildSeoHead() into native head APIs.
Shared GROQ Fragment
These examples use the actual field names created by seoFields. The equivalent of metaTitle is seo.title, and the equivalent of metaDescription is seo.description.
export const SEO_FRAGMENT = `{
title,
seo {
title,
description,
canonicalUrl,
metaImage { asset-> { url }, alt },
keywords,
robots { noIndex, noFollow, noTranslate, noImageIndex },
hreflangs[] { locale, url },
openGraph {
title, description, url, siteName, type,
imageType, imageUrl,
image { asset-> { url }, alt }
},
twitter {
card, site, creator, title, description,
imageType, imageUrl,
image { asset-> { url }, alt }
},
metaAttributes[] { _key, key, type, value }
}
}`
export const PAGE_QUERY = `
*[_type == "page" && slug.current == $slug][0] ${SEO_FRAGMENT}
`
Next.js App Router
Use buildSeoMeta() inside generateMetadata(). It returns a value compatible with Next.js Metadata, including canonical URL, robots directives, Open Graph, Twitter/X card tags, and hreflang alternates.
// app/[slug]/page.tsx
import type { Metadata } from 'next'
import { buildSeoMeta } from 'sanity-plugin-seofields/next'
import { client } from '@/sanity/lib/client'
import { urlFor } from '@/sanity/lib/image'
import { PAGE_QUERY } from '@/sanity/lib/queries'
export async function generateMetadata(props: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await props.params
const page = await client.fetch(PAGE_QUERY, { slug })
return buildSeoMeta({
seo: page?.seo,
baseUrl: 'https://example.com',
path: '/' + slug,
defaults: {
title: page?.title || 'My Site',
description: 'Default site description',
siteName: 'My Site',
twitterSite: '@mysite',
},
imageUrlResolver: (image) => urlFor(image).width(1200).height(630).url(),
})
}
export default async function Page(props: {
params: Promise<{ slug: string }>
}) {
const { slug } = await props.params
const page = await client.fetch(PAGE_QUERY, { slug })
return <main>{page?.title}</main>
}Next.js Pages Router
If you still use the Pages Router, render <SeoMetaTags> inside Next.js <Head>.
// pages/[slug].tsx
import Head from 'next/head'
import { SeoMetaTags } from 'sanity-plugin-seofields/next'
import { urlFor } from '@/sanity/lib/image'
export default function Page({ page }) {
return (
<>
<Head>
<SeoMetaTags
data={page?.seo}
baseUrl="https://example.com"
path={'/' + page?.slug}
defaults={{ title: page?.title, siteName: 'My Site' }}
imageUrlResolver={(image) => urlFor(image).width(1200).height(630).url()}
/>
</Head>
<main>{page?.title}</main>
</>
)
}
Astro
Astro can consume buildSeoHead() in frontmatter and pass the plain arrays into a layout. The layout owns the final <head> rendering.
// 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',
})--- // src/pages/[slug].astro
import { buildSeoHead } from 'sanity-plugin-seofields/head'
import { client } from '@/lib/sanity'
import Layout from '@/layouts/Layout.astro'
import { PAGE_QUERY } from '@/lib/queries'
const { slug } = Astro.params
const page = await client.fetch(PAGE_QUERY, { slug })
if (!page) {
return Astro.redirect('/404')
}
const head = buildSeoHead({
seo: page.seo,
baseUrl: 'https://example.com',
path: '/' + slug,
defaults: {
title: page.title,
description: 'Default site description',
siteName: 'My Site',
},
})
---
<Layout head={head}>
<h1>{page.title}</h1>
</Layout>--- // src/layouts/Layout.astro
const { head } = Astro.props
---
<!doctype html>
<html lang="en">
<head>
<title>{head.title}</title>
{head.meta.map((tag) =>
'property' in tag ? (
<meta property={tag.property} content={tag.content} />
) : (
<meta name={tag.name} content={tag.content} />
)
)}
{head.link.map((tag) => (
<link rel={tag.rel} href={tag.href} hreflang={tag.hreflang} />
))}
</head>
<body>
<slot />
</body>
</html>
Nuxt 3
Nuxt uses useHead(). Pass the output of buildSeoHead() directly to it and Nuxt will render the tags server-side.
// composables/useSeoData.ts
import { createClient } from '@sanity/client'
import { PAGE_QUERY } from '~/lib/queries'
const client = createClient({
projectId: useRuntimeConfig().public.sanityProjectId,
dataset: useRuntimeConfig().public.sanityDataset || 'production',
useCdn: false,
apiVersion: '2024-01-01',
})
export async function useSeoData(slug: string) {
return client.fetch(PAGE_QUERY, { slug })
}<!-- pages/[slug].vue -->
<script setup lang="ts">
import { buildSeoHead } from 'sanity-plugin-seofields/head'
const route = useRoute()
const page = await useSeoData(route.params.slug as string)
const head = buildSeoHead({
seo: page?.seo,
baseUrl: 'https://example.com',
path: '/' + route.params.slug,
defaults: {
title: page?.title || 'My Site',
description: 'Default site description',
siteName: 'My Site',
},
})
useHead({
title: head.title,
meta: head.meta,
link: head.link,
})
</script>
<template>
<main>
<h1>{{ page?.title }}</h1>
</main>
</template>
Vue 3 Standalone
In Vite/Vue projects, use @unhead/vue or your preferred head manager. The data shape from buildSeoHead() matches the common Unhead/Nuxt format.
// main.ts
import { createApp } from 'vue'
import { createHead } from '@unhead/vue/client'
import App from './App.vue'
const app = createApp(App)
const head = createHead()
app.use(head)
app.mount('#app')<!-- src/pages/Page.vue -->
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { buildSeoHead } from 'sanity-plugin-seofields/head'
import { client } from '@/lib/sanity'
import { PAGE_QUERY } from '@/lib/queries'
const props = defineProps<{ slug: string }>()
const page = await client.fetch(PAGE_QUERY, { slug: props.slug })
const head = buildSeoHead({
seo: page?.seo,
baseUrl: 'https://example.com',
path: '/' + props.slug,
defaults: { title: page?.title || 'My Site', siteName: 'My Site' },
})
useHead({
title: head.title,
meta: head.meta,
link: head.link,
})
</script>
<template>
<main>
<h1>{{ page?.title }}</h1>
</main>
</template>
SvelteKit
SvelteKit keeps data loading in +page.ts and head rendering in +page.svelte. Return the head data from the load function and render it with <svelte:head>.
// src/lib/sanity.ts
import { createClient } from '@sanity/client'
export 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',
})// src/routes/[slug]/+page.ts
import { buildSeoHead } from 'sanity-plugin-seofields/head'
import type { PageLoad } from './$types'
import { client } from '$lib/sanity'
import { PAGE_QUERY } from '$lib/queries'
export const load: PageLoad = async ({ params }) => {
const page = await client.fetch(PAGE_QUERY, { slug: params.slug })
return {
page,
head: buildSeoHead({
seo: page?.seo,
baseUrl: 'https://example.com',
path: '/' + params.slug,
defaults: {
title: page?.title || 'My Site',
description: 'Default site description',
siteName: 'My Site',
},
}),
}
}<!-- src/routes/[slug]/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types'
export let data: PageData
</script>
<svelte:head>
{#if data.head.title}
<title>{data.head.title}</title>
{/if}
{#each data.head.meta as tag}
{#if 'property' in tag}
<meta property={tag.property} content={tag.content} />
{:else}
<meta name={tag.name} content={tag.content} />
{/if}
{/each}
{#each data.head.link as tag}
<link rel={tag.rel} href={tag.href} hreflang={tag.hreflang} />
{/each}
</svelte:head>
<main>
<h1>{data.page?.title}</h1>
</main>buildSeoHead() Output
This helper is intentionally small and serializable. It is safe to return from loaders, pass to layouts, or transform into another head manager's preferred format.
interface SeoHead {
title?: string | null
meta: Array<
| { name: string; content: string }
| { property: string; content: string }
>
link: Array<{
rel: string
href: string
hreflang?: string
}>
}buildSeoMeta() Options
interface BuildSeoMetaOptions {
/** Raw SEO object from Sanity. Pass null/undefined to use only defaults. */
seo?: SeoFieldsInput | null
/** Base URL of your site, e.g. "https://example.com". Used for canonical + og:url. */
baseUrl?: string
/** Current page path, e.g. "/about". Combined with baseUrl for canonical + og:url. */
path?: string
/** Fallback values used when SEO fields are missing. */
defaults?: {
title?: string
description?: string
siteName?: string
twitterSite?: string
twitterCreator?: string
/** Fallback OG/Twitter image URL when no image is set in Sanity. */
ogImage?: string
}
/** Resolve a Sanity image asset reference to a full URL string. */
imageUrlResolver?: (image: SanityImage | SanityImageWithAlt) => string | null | undefined
/** Override hreflang alternates. Pair with buildHreflangs() to derive them from translations. */
hreflangs?: Array<{ locale?: string | null; url?: string | null }> | null
}Generate an llms.txt
Build an llms.txt file from your Sanity content with buildLlmsTxt() and docsToLlmsSection() — framework-neutral helpers exported from /head and /next. Serve the string from a /llms.txt route or a build step.
import { buildLlmsTxt, docsToLlmsSection } from 'sanity-plugin-seofields/head'
const body = buildLlmsTxt({
title: 'Acme',
summary: 'Everything Acme, for humans and LLMs.',
baseUrl: 'https://acme.com',
sections: [
docsToLlmsSection(posts, { title: 'Blog', baseUrl: 'https://acme.com' }),
docsToLlmsSection(docsPages, { title: 'Docs', baseUrl: 'https://acme.com' }),
],
})JSON-LD quality rule: render structured data on the canonical page, keep it consistent with visible content, escape or sanitize user-generated strings before injecting scripts, and validate output with Google Rich Results Test plus Schema.org Validator.
Was this page helpful?