SEO and Meta
Search engine optimization in a Nuxt app starts with getting meta tags right. Google, Bing, and social media crawlers read your <head> to understand what a page is about, how to display it in search results, and what image to show when someone shares a link on Twitter or LinkedIn. Nuxt provides composables that make this straightforward, but the details matter — a missing canonical URL or a wrong Open Graph image dimension can quietly hurt your traffic for months before you notice.
useHead
useHead is the primary composable for setting anything in <head>. It works in pages, layouts, and components, and it is SSR-aware — tags render on the server so crawlers see them immediately.
<script setup lang="ts">
useHead({
title: 'Pricing Plans',
meta: [
{ name: 'description', content: 'Compare our pricing plans. Start free, upgrade when you need to.' },
],
link: [
{ rel: 'canonical', href: 'https://myapp.com/pricing' },
],
})
</script>
For dynamic pages, pass reactive values:
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
useHead({
title: () => post.value?.title || 'Blog',
meta: [
{ name: 'description', content: () => post.value?.excerpt || '' },
],
})
</script>
Title Templates
Set a title template in your nuxt.config.ts or app.vue to avoid repeating your site name on every page:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
titleTemplate: '%s | Acme Corp',
},
},
})
Or dynamically in app.vue:
<!-- app.vue -->
<script setup lang="ts">
useHead({
titleTemplate: (title) => {
return title ? `${title} | Acme Corp` : 'Acme Corp'
},
})
</script>
Individual pages set just the page-specific part:
<script setup lang="ts">
useHead({ title: 'About Us' })
// Renders as: "About Us | Acme Corp"
</script>
useSeoMeta
useSeoMeta is a convenience wrapper that provides type-safe access to all SEO meta tags. It is the preferred way to set Open Graph, Twitter, and standard meta tags because typos get caught at build time.
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
useSeoMeta({
title: () => post.value?.title,
description: () => post.value?.excerpt,
ogTitle: () => post.value?.title,
ogDescription: () => post.value?.excerpt,
ogImage: () => post.value?.coverImage,
ogType: 'article',
ogUrl: () => `https://myapp.com/blog/${route.params.slug}`,
twitterCard: 'summary_large_image',
twitterTitle: () => post.value?.title,
twitterDescription: () => post.value?.excerpt,
twitterImage: () => post.value?.coverImage,
articlePublishedTime: () => post.value?.publishedAt,
articleAuthor: () => post.value?.author?.name,
})
</script>
The difference from useHead: useSeoMeta only handles meta tags (not link, script, or style), but it gives you autocomplete for every standard SEO tag. Use useSeoMeta for meta tags and useHead for everything else.
Open Graph Tags
Open Graph controls how your pages look when shared on Facebook, LinkedIn, Slack, Discord, and iMessage. The minimum viable set:
<script setup lang="ts">
useSeoMeta({
ogTitle: 'How We Migrated 2M Users to Vue 3',
ogDescription: 'A step-by-step account of migrating a large Vue 2 app.',
ogImage: 'https://myapp.com/images/og/vue3-migration.png',
ogUrl: 'https://myapp.com/blog/vue3-migration',
ogType: 'article',
ogSiteName: 'Acme Engineering Blog',
})
</script>
OG Image Dimensions
Facebook recommends 1200x630 pixels. Twitter prefers 1200x600 for summary_large_image. Create images at 1200x630 as a compromise — both platforms handle it well.
For dynamic OG images (pages where you cannot pre-design an image), consider generating them with a server route:
// server/api/og-image.get.ts
import { H3Event } from 'h3'
export default defineEventHandler(async (event: H3Event) => {
const { title, subtitle } = getQuery(event)
// Use @vercel/og, satori, or a similar library
// to generate a PNG from HTML/CSS
const image = await generateOgImage({
title: title as string,
subtitle: subtitle as string,
})
setResponseHeader(event, 'Content-Type', 'image/png')
setResponseHeader(event, 'Cache-Control', 'public, max-age=86400')
return image
})
Then reference it in your meta:
<script setup lang="ts">
const title = 'How We Migrated 2M Users to Vue 3'
useSeoMeta({
ogImage: `https://myapp.com/api/og-image?title=${encodeURIComponent(title)}`,
})
</script>
Structured Data (JSON-LD)
Structured data tells Google what your content is — an article, a product, a recipe, a FAQ. It powers rich results in search: star ratings, price ranges, FAQ dropdowns, breadcrumbs.
Add JSON-LD with useHead:
<!-- pages/products/[id].vue -->
<script setup lang="ts">
const { data: product } = await useFetch(`/api/products/${route.params.id}`)
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.value?.name,
description: product.value?.description,
image: product.value?.images,
offers: {
'@type': 'Offer',
price: product.value?.price,
priceCurrency: 'USD',
availability: product.value?.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
}),
},
],
})
</script>
For articles and blog posts:
<script setup lang="ts">
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.value?.title,
datePublished: post.value?.publishedAt,
dateModified: post.value?.updatedAt,
author: {
'@type': 'Person',
name: post.value?.author?.name,
},
image: post.value?.coverImage,
publisher: {
'@type': 'Organization',
name: 'Acme Corp',
logo: {
'@type': 'ImageObject',
url: 'https://myapp.com/logo.png',
},
},
}),
},
],
})
</script>
Validate your structured data with Google's Rich Results Test (search.google.com/test/rich-results) before deploying.
Sitemap Generation
Use @nuxtjs/sitemap to automatically generate a sitemap from your routes:
npx nuxi module add @nuxtjs/sitemap
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/sitemap'],
site: {
url: 'https://myapp.com',
},
})
For dynamic routes that Nuxt cannot discover automatically, provide them via an API endpoint or configuration:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/sitemap'],
site: {
url: 'https://myapp.com',
},
sitemap: {
sources: ['/api/__sitemap__/urls'],
},
})
// server/api/__sitemap__/urls.ts
export default defineSitemapEventHandler(async () => {
const posts = await $fetch('/api/posts')
return posts.map((post: { slug: string; updatedAt: string }) => ({
loc: `/blog/${post.slug}`,
lastmod: post.updatedAt,
changefreq: 'weekly',
priority: 0.8,
}))
})
Robots.txt and Crawl Control
The @nuxtjs/robots module handles robots.txt:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/robots'],
robots: {
rules: [
{ UserAgent: '*', Disallow: '/admin' },
{ UserAgent: '*', Disallow: '/api' },
{ UserAgent: '*', Allow: '/' },
{ Sitemap: 'https://myapp.com/sitemap.xml' },
],
},
})
For per-page control, use the robots meta tag:
<!-- pages/admin/index.vue -->
<script setup lang="ts">
useSeoMeta({
robots: 'noindex, nofollow',
})
</script>
Canonical URLs
Canonical URLs tell search engines which version of a page is the "real" one. This matters when you have pages accessible at multiple URLs (with and without trailing slashes, with query parameters, HTTP vs HTTPS).
<script setup lang="ts">
const route = useRoute()
const canonicalUrl = `https://myapp.com${route.path}`
useHead({
link: [
{ rel: 'canonical', href: canonicalUrl },
],
})
useSeoMeta({
ogUrl: canonicalUrl,
})
</script>
For a global solution, set canonical URLs in a plugin or layout:
// plugins/canonical.ts
export default defineNuxtPlugin(() => {
const route = useRoute()
const config = useRuntimeConfig()
useHead({
link: [
{
rel: 'canonical',
href: () => `${config.public.siteUrl}${route.path}`,
},
],
})
})
Common Pitfalls
Duplicating meta tags across useHead and useSeoMeta. If you set ogTitle in both useHead (via the meta array) and useSeoMeta, you get duplicate tags. Pick one approach per tag.
Forgetting og:image dimensions. Without ogImageWidth and ogImageHeight, platforms have to fetch and parse the image to get its size, which sometimes causes the preview to fail on first share.
Not setting canonical URLs. Without canonicals, Google may index the wrong version of your page — the one with query parameters, a trailing slash, or HTTP instead of HTTPS. This splits your page authority.
Using relative URLs in meta tags. Open Graph and canonical tags require absolute URLs. /blog/my-post will not work — it must be https://myapp.com/blog/my-post.
Ignoring structured data validation. A typo in your JSON-LD will silently fail. Google will not show rich results and will not tell you why. Always validate with the Rich Results Test.
Key Takeaways
- Use
useSeoMetafor meta tags (type-safe, readable) anduseHeadfor link, script, and style tags. - Set a
titleTemplateglobally and override individual page titles. - Open Graph images should be 1200x630 pixels with absolute URLs.
- Add JSON-LD structured data for rich search results — validate it before deploying.
- Canonical URLs prevent duplicate content issues; set them globally via a plugin.
- Use
@nuxtjs/sitemapand@nuxtjs/robotsmodules rather than managing these files manually.