Nuxt useSeoMeta is the composable that writes your page's meta tags: the description, the Open Graph tags that build link previews, and the Twitter card tags. It is one part of the SEO setup every Nuxt app needs. The others are a title template, a canonical URL on every page, a sitemap, a robots.txt and OG images that platforms can actually show. This post sets up all of them in a Nuxt 4 app, with the same patterns quasarui.com runs on.
Start with the title. app.head in nuxt.config.ts sets static head tags for the whole app, including a default title and a template that every page title is placed into:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
title: 'Acme',
titleTemplate: '%s | Acme',
htmlAttrs: { lang: 'en' },
},
},
})%s is replaced by the page's title, so a page that sets title: 'Pricing' gets Pricing | Acme. The template can also be a function, but a function can't go in nuxt.config. Put it in useHead in app.vue instead.
The other defaults belong in app.vue, because app.head can't take reactive data and the defaults usually need the current URL:
<!-- app/app.vue -->
<script setup lang="ts">
const site = 'https://acme.dev'
const route = useRoute()
const canonical = computed(() => `${site}${route.path === '/' ? '' : route.path}`)
useSeoMeta({
description: 'Acme tracks product events without a data team.',
ogSiteName: 'Acme',
ogType: 'website',
ogUrl: canonical,
ogImage: `${site}/og.jpg`,
ogImageWidth: 1200,
ogImageHeight: 630,
ogImageAlt: 'The Acme dashboard',
twitterCard: 'summary_large_image',
})
useHead({
link: [{ rel: 'canonical', href: canonical }],
})
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>Any page that sets one of these tags again replaces the default, so every page has a description and an image even if you forget one.
useSeoMeta takes a flat object of tags with TypeScript types for each key. The Nuxt docs recommend it for meta tags because it is XSS safe, and the types catch mistakes like writing name where Open Graph wants property. A typed key such as ogDescription writes the right attribute for you.
A static page:
<!-- app/pages/pricing.vue -->
<script setup lang="ts">
useSeoMeta({
title: 'Pricing',
description: 'Three plans, monthly or yearly. Every plan starts with a 14-day trial.',
ogTitle: 'Acme pricing',
ogDescription: 'Three plans, monthly or yearly. Every plan starts with a 14-day trial.',
})
</script>A page whose tags depend on fetched data passes getters. The docs call this the computed getter syntax (() => value), and it keeps the tags in sync when the data changes:
<!-- app/pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useFetch(`/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?.coverUrl,
ogType: 'article',
})
</script>Search engine crawlers mostly read the first server render, so most tags don't need to be reactive at all. The docs suggest wrapping static tags in if (import.meta.server) { ... } so they are only added during server rendering. The older useServerSeoMeta composable did the same thing and is now deprecated in favor of that condition.
Two rules for the values:
A canonical link tells search engines which URL is the real one when the same page is reachable in several ways: with and without a trailing slash, with tracking parameters like ?utm_source=..., or on both www and the bare domain.
The app.vue above builds it from route.path, which has no query string, so ?utm_source=x never leaks into the canonical. Two details to get right:
runtimeConfig and read it with useRuntimeConfig(), so each environment sets its own.www, the canonical should use www too, or the canonical URL points at a redirect.Files in server/routes/ become routes without the /api prefix, so server/routes/sitemap.xml.ts answers /sitemap.xml. (The Nuxt 4 folder structure guide covers where these folders live.) A sitemap is a list of URLs, which a short handler can build:
// server/routes/sitemap.xml.ts
import { queryCollection } from '@nuxt/content/server'
const STATIC_PATHS = ['/', '/pricing', '/blog', '/docs']
export default defineEventHandler(async (event) => {
const origin = useRuntimeConfig(event).public.siteUrl
const posts = await queryCollection(event, 'blog').select('path').all()
const urls = [...STATIC_PATHS, ...posts.map(post => post.path)]
.map(path => ` <url><loc>${origin}${path === '/' ? '' : path}</loc></url>`)
.join('\n')
setHeader(event, 'content-type', 'application/xml; charset=utf-8')
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`
})This version reads blog posts from Nuxt Content. Replace that query with your own data source, such as a database call or a list of products. Leave out pages that are noindex: sign-in, dashboards, checkout.
robots.txt is a route of its own and points crawlers at the sitemap:
// server/routes/robots.txt.ts
export default defineEventHandler((event) => {
const origin = useRuntimeConfig(event).public.siteUrl
setHeader(event, 'content-type', 'text/plain; charset=utf-8')
return `User-agent: *\nDisallow:\n\nSitemap: ${origin}/sitemap.xml\n`
})Both read siteUrl from public runtime config, declared as runtimeConfig: { public: { siteUrl: '' } } in nuxt.config.ts and set per environment with NUXT_PUBLIC_SITE_URL. The Nuxt docs recommend passing event to useRuntimeConfig in server routes, so values from environment variables apply at runtime.
If you'd rather not keep the path list in sync by hand, the @nuxtjs/sitemap module discovers your app's pages and serves /sitemap.xml for you. It needs a site URL set, and dynamic routes (from a CMS or a database) still need a source you configure. A hand-written route is around 20 lines and has no dependency. The module saves work when you have many dynamic sources or need a sitemap index.
The og:image tag picks the picture in a link preview on X, LinkedIn, Slack, Discord and Facebook. Most broken previews come down to three mistakes:
og:image must be absolute: https://acme.dev/og.jpg, not /og.jpg.og:image at a file that won't show.ogImageWidth and ogImageHeight so platforms can lay out the card before they download the image.Pair it with twitterCard: 'summary_large_image' for the large card on X. Without it, X falls back to the smaller summary card.
For one image per page, store a 1200×630 PNG next to each post and set ogImage from the post data, as in the [slug].vue example. If you have hundreds of pages, modules such as nuxt-og-image render the images from a Vue component at build or request time. That is more setup, so start with static files until the page count makes it painful.
Search engines also read JSON-LD. useHead can add it as a script tag:
useHead(() => ({
script: [{
key: 'article-schema',
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
'headline': post.value?.title,
'datePublished': post.value?.date,
'author': { '@type': 'Person', 'name': post.value?.author },
}),
}],
}))The key keeps a second call from adding a duplicate tag. Only stringify data you control: this is raw HTML in the head.
View the page source (not the DevTools elements panel) to see what the server sent, since that's what crawlers read. Or fetch it from a terminal:
curl -s https://acme.dev/pricing | grep -E '<title>|og:|canonical'
curl -s https://acme.dev/sitemap.xml | headThen paste a URL into each platform's preview or post inspector. They cache previews, so check before the first share.
useHead or useSeoMeta? useSeoMeta for meta tags, useHead for everything else: links like the canonical, scripts, htmlAttrs.
Do I need SSR for SEO in Nuxt? The tags have to be in the HTML a crawler receives. Server rendering or prerendering does that. A client-only page only gets its tags after JavaScript runs.
Can I set the title in definePageMeta? You can, but it is read at build time by a macro, so it can't be dynamic. Use useSeoMeta for titles that depend on data.
Why does my preview show an old image? Platforms cache previews. Use their inspector to refresh it, or change the image URL.
If you're starting an admin app rather than a marketing site, the 4 QuasarUI Templates are full multi-page Quasar apps (Rambo, Avocado, Minimal Dashboard and Data-Focused Dashboard) with dark mode, delivered as a private GitHub repo. Start from one and spend your time on the product instead of the layout.