Skip to main content
seofields
docs
.md

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.

Next.js
Astro
Nuxt
Vue
SvelteKit
RRemix

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.

sanity/lib/queries.ts
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
// 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
// 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
// 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
--- // 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
--- // 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
// 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
<!-- 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
// 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
<!-- 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
// 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
// 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
<!-- 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.

SeoHead
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

BuildSeoMetaOptions
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.

app/llms.txt/route.ts
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?