Dynamic Meta Tags with useHead in Nuxt 3

The point of dynamic metadata is that titles, social tags, and structured data come from the same data that renders the page — and in Nuxt 3 the trick is making sure that data is resolved before the server renders the head. Done right, useSeoMeta and useHead emit complete, correct metadata into the response. Done wrong — data fetched too late or bound non-reactively — the head renders empty. This guide is the data-driven implementation under Nuxt 3 SEO and meta management.

Await the data before Nuxt renders the head When useAsyncData is awaited, the data is ready before SSR composes the head and the tags resolve; when it is not, the server renders the head early and the tags come out empty. The head is composed once during SSR — data must arrive first awaited useAsyncData await fetch data resolved useSeoMeta getters compose head full tags in response non-awaited / non-reactive head renders data still pending values undefined nothing to bind empty head crawler sees nothing
Awaiting the fetch and binding with getters keeps the head composition after the data lands; skip either and the server emits an empty head.

Step-by-step fix

  1. Await the route data so it exists at render time.

    <script setup>
    const route = useRoute();
    // ✅ awaited: available during SSR, so the head can use it
    const { data: post } = await useAsyncData(`post-${route.params.slug}`,
      () => $fetch(`/api/posts/${route.params.slug}`));
    </script>
  2. Bind metadata reactively with getter functions. Passing values directly can capture them before they resolve; getters track the data.

    <script setup>
    useSeoMeta({
      title:       () => post.value.title,        // ✅ getter tracks data
      description: () => post.value.summary,
      ogTitle:     () => post.value.title,
      ogImage:     () => post.value.ogImage,
      twitterCard: 'summary_large_image',
    });
    </script>
    <script setup>
    // ❌ Non-reactive: may serialize before the fetch resolves
    useSeoMeta({ title: post.value?.title });
    </script>
  3. Add JSON-LD and canonical with useHead.

    <script setup>
    useHead({
      link: [{ rel: 'canonical', href: () => `https://example.com/blog/${route.params.slug}` }],
      script: [{
        type: 'application/ld+json',
        innerHTML: () => JSON.stringify({
          '@context': 'https://schema.org', '@type': 'Article',
          headline: post.value.title, datePublished: post.value.date,
        }),
      }],
    });
    </script>
Which head entries belong to useSeoMeta versus useHead useSeoMeta handles typed SEO tags like title, description, and Open Graph, while useHead handles arbitrary head entries like the canonical link and the JSON-LD script; both compose into one server-rendered document head. Two composables, one server-rendered head useSeoMeta typed SEO tags title • description ogTitle • ogImage twitterCard useHead arbitrary head entries canonical link JSON-LD script custom meta / links document <head>
Reach for useSeoMeta for the typed SEO tags and useHead for the canonical link and JSON-LD; both merge into one head emitted during SSR.

Validation

  • curl of the route shows the resolved title, OG tags, and JSON-LD in the response.
  • GSC URL Inspection rendered HTML matches the live head.
  • Rich Results Test validates the Article JSON-LD.
  • Social debuggers show the correct OG image and title.
Four validation checks and the guarantee each one proves Each useHead validation step maps to a distinct property: server-rendered tags, render parity, valid structured data, and a correct social card. Each check proves one thing — run all four curl the route Tags ship in the raw HTML response GSC URL Inspection Rendered head matches the live head Rich Results Test Article JSON-LD is valid and eligible Social debuggers Card shows the right image and title
Each validation step confirms a different guarantee — from tags reaching the raw response to the social card rendering correctly.

Reference

<script setup>
const route = useRoute();
const { data: post } = await useAsyncData(`post-${route.params.slug}`,
  () => $fetch(`/api/posts/${route.params.slug}`));

useSeoMeta({
  title: () => post.value.title,
  description: () => post.value.summary,
  ogTitle: () => post.value.title,
  ogDescription: () => post.value.summary,
  ogImage: () => post.value.ogImage,
});
useHead({
  link: [{ rel: 'canonical', href: `https://example.com/blog/${route.params.slug}` }],
});
</script>
One awaited post object feeds every head tag A single awaited post object is the source for the title, meta description, Open Graph image, canonical link, and Article JSON-LD, so one fetch drives the whole head. One fetched object is the single source for the whole head post awaited useAsyncData title — post.title description — post.summary og:image — post.ogImage canonical — post.slug JSON-LD — Article
Because every tag reads from the same awaited object, one fetch keeps the title, social tags, canonical, and structured data consistent in the SSR response.

Frequently Asked Questions

Why are my Nuxt meta tags empty in the page source? The data the tags depend on was not awaited before rendering, so the server rendered the head before the values existed. Use await with useFetch or useAsyncData and pass getter functions to useSeoMeta so the tags resolve during SSR.

How do I add JSON-LD structured data in Nuxt 3? Use useHead with a script entry of type application/ld+json and your serialized structured data. Because it runs during SSR, the JSON-LD is present in the server response for crawlers to parse.

← Back to Nuxt 3 SEO & Meta Management