Building Nuxt apps

SEO, head and media

useSeoMeta and useHead on Unhead, titles and OG tags, canonical URLs, sitemaps and structured data with the Nuxt SEO modules, and the image, font and script modules that protect LCP and CLS.

Server rendering exists largely for this page: crawlers and link previews read the HTML the server produced, so titles, meta tags and images have to be right in that first response. The interview questions are practical: how do you set a title per page, how do you produce OG images, how do you stop fonts causing layout shift, how do you keep a hero image from being the LCP bottleneck.

Know

  • Unhead (v3 in Nuxt 4.5) powers head management. useHead({ title, meta, link, script, htmlAttrs, bodyAttrs }) accepts refs and getters and updates reactively; useSeoMeta({ title, description, ogTitle, ogImage, twitterCard }) is the typed, flat form for meta tags and is what you should reach for first. useHeadSafe sanitises untrusted input (it whitelists attributes and strips innerHTML). For tags that never change after load, wrap the call in if (import.meta.server) { useSeoMeta(...) } so the client does no head work; the older useServerSeoMeta is deprecated in favour of that pattern.
  • Defaults live in nuxt.config.ts under app.head (htmlAttrs.lang, titleTemplate: '%s · Site', favicons, default OG image) and pages override per route. Tags deduplicate by key; tagPriority and capo-style ordering put critical tags first.
  • Absolute URLs for og:image, canonical and sitemap entries: useRequestURL() on the server, or nuxt-site-config (useSiteConfig()) to hold the canonical site URL per environment.
  • The Nuxt SEO suite (@nuxtjs/seo bundles @nuxtjs/sitemap, @nuxtjs/robots, nuxt-og-image, nuxt-schema-org, nuxt-link-checker, nuxt-site-config) covers sitemaps from routes and content, robots rules per environment, OG images rendered from Vue components at build or request time, and JSON-LD structured data through useSchemaOrg.
  • @nuxt/image: <NuxtImg> generates responsive srcset/sizes and modern formats through a provider (IPX locally, Cloudflare Images, Cloudinary and others in production); <NuxtPicture> adds art direction and format fallbacks. Everything below the fold gets loading="lazy"; the LCP image gets preload and fetchpriority="high" and never lazy loads.
  • @nuxt/fonts detects font-family declarations, downloads and self-hosts the files, sets font-display, and generates metric-compatible fallback fonts, which is the fix for the layout shift when a web font swaps in. No <link> to Google Fonts, no third-party request.
  • @nuxt/scripts loads third-party scripts (analytics, maps, embeds) with triggers (onNuxtReady, visibility, interaction, manual), typed registries (useScriptGoogleAnalytics), and facades that render a placeholder until the user interacts. It exists because third-party scripts are the biggest uncontrolled cost on most sites.
  • Crawlability basics: one <h1>, semantic landmarks, rel="canonical" on paginated and filtered pages, noindex on search results and private areas, hreflang for locales (the i18n page), meaningful 404 statuses (error handling), and prerendering or caching the pages crawlers hit most (rendering strategies).

How it works

Site-wide defaults, then a product page that overrides them and emits structured data:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/image', '@nuxt/fonts', '@nuxt/scripts', '@nuxtjs/seo'],
  app: {
    head: {
      htmlAttrs: { lang: 'en' },
      titleTemplate: '%s · Shop',
      link: [{ rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }],
    },
  },
  site: { url: 'https://shop.example.com', name: 'Shop' }, // nuxt-site-config, read by sitemap, robots, og-image
  image: { provider: 'cloudflare', cloudflare: { baseURL: 'https://shop.example.com' } },
})
app/pages/products/[slug].vue
<script setup lang="ts">
const { params } = useRoute()
const { data: product } = await useFetch(`/api/products/${params.slug}`)
if (!product.value) throw createError({ status: 404, fatal: true })

const url = useRequestURL()

useSeoMeta({
  title: () => product.value!.name,
  description: () => product.value!.summary,
  ogTitle: () => product.value!.name,
  ogDescription: () => product.value!.summary,
  ogImage: () => new URL(product.value!.image, url.origin).href,
  ogType: 'product',
  twitterCard: 'summary_large_image',
})
useHead({ link: [{ rel: 'canonical', href: `${url.origin}/products/${params.slug}` }] })

useSchemaOrg([
  defineProduct({
    name: product.value.name,
    image: product.value.image,
    offers: [{ price: product.value.price, priceCurrency: 'EUR' }],
  }),
])
</script>

<template>
  <article>
    <!-- The LCP element: eager, preloaded, high priority, sized to avoid layout shift -->
    <NuxtImg
      :src="product!.image"
      :alt="product!.name"
      width="1200"
      height="800"
      sizes="100vw md:50vw"
      format="avif,webp"
      preload
      fetchpriority="high"
    />
    <h1>{{ product!.name }}</h1>
    <!-- Below the fold: lazy -->
    <NuxtImg v-for="img in product!.gallery" :key="img" :src="img" alt="" width="600" height="400" loading="lazy" />
  </article>
</template>

Third-party scripts without paying for them up front:

app/components/Site/Map.vue
<script setup lang="ts">
// Loads the Google Maps script only when the placeholder is visible and the user has interacted.
const { onLoaded } = useScriptGoogleMaps({ apiKey: useRuntimeConfig().public.mapsKey, scriptOptions: { trigger: 'visible' } })
onLoaded(({ maps }) => { /* create the map */ })
</script>
Gotcha· A reactive title that is not reactive

useSeoMeta({ title: product.value.name }) captures the string once. Pass a getter (() => product.value.name) or a ref so the tag follows the data, which matters on client-side navigation between products.

Gotcha· Lazy-loading the hero

loading="lazy" on the largest above-the-fold image delays the LCP element until layout is known. The LCP image is eager with fetchpriority="high" and, ideally, preload; everything else is lazy.

Verify before the interview:

The current module names bundled by @nuxtjs/seo and the useSchemaOrg helper names (defineProduct, defineArticle, …); the suite evolves quickly.

docs ↗

Exercise

Exercise
  • Add useSeoMeta with getters to a detail page, navigate between two items client-side, and confirm the document title and the OG meta update. Then curl both URLs and check the server HTML has the right tags.
  • Install @nuxt/fonts, replace a Google Fonts <link>, and measure CLS with Lighthouse before and after.
  • Set up nuxt-og-image with a component template and open /__og-image__/ in dev to preview the rendered image.

Be able to say

Be able to say· How do you handle SEO in a Nuxt application?

"Server rendering gives me crawlable HTML; the rest is discipline. Site defaults live in app.head: language, title template, favicons, a default OG image. Each page sets useSeoMeta with getters so title, description and OG tags follow the data on client-side navigation, and a canonical link built from useRequestURL. The Nuxt SEO modules generate the sitemap and robots rules per environment, render OG images from a Vue template, and emit JSON-LD through useSchemaOrg. Then I make sure errors return real statuses, paginated and filtered pages have canonicals or noindex, and the pages crawlers hit most are prerendered or cached."

Be able to say· How do you keep images and fonts from hurting Core Web Vitals?

"Images through @nuxt/image: NuxtImg generates responsive srcset and modern formats via a provider, every image has width and height so space is reserved, everything below the fold is lazy, and the LCP image is eager with preload and fetchpriority=\"high\". Fonts through @nuxt/fonts: it self-hosts the files, sets font-display, and generates metric-compatible fallbacks so the swap does not shift layout, which is the usual CLS culprit. Third-party scripts through @nuxt/scripts with load triggers, because an analytics or maps script loaded eagerly is often the single largest cost on the page."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.