SEO and Metadata
Per-route titles, Open Graph tags, JSON-LD and a sitemap, and why a pure SPA gives crawlers nothing to read.
useSeoMeta({ title, description, ogTitle, ogDescription, ogImage, twitterCard })A crawler or a link preview reads the HTML of the response. In a pure SPA that HTML is an empty shell: the title is whatever is in index.html and there is no content. Search engines may run the JavaScript and see more eventually, but the crawlers behind social link previews do not run it at all, which is why a shared URL comes back blank. Metadata is the main reason an otherwise private-feeling app ends up needing server rendering or prerendering.
Setting tags per route
In Nuxt, useSeoMeta takes a flat, typed object: title, description, ogTitle, ogDescription, ogImage, twitterCard and the rest of the Open Graph and Twitter names as camelCase keys. It exists because hand-writing <meta property="og:title"> invites typos that nothing catches.
useHead is the longhand underneath it, and the two are interchangeable. It takes title, titleTemplate, and arrays of meta, link, script and style entries. For user-supplied values prefer useHeadSafe, which strips dangerous entries. Static site-wide defaults belong in app.head in nuxt.config.ts.
Both accept getters, so title: () => post.value?.title updates as data arrives.
Structured data and sitemaps
Structured data is a script entry with type: 'application/ld+json' and a JSON string. A sitemap is just a route that returns XML: a server route can build it from the same data the pages use, and prerendering writes it to a file.
Example
<!-- app/pages/blog/[slug].vue -->
<script setup lang="ts">
type Post = { slug: string; title: string; excerpt: string; image: string }
const site = 'https://example.com'
const route = useRoute()
const { data: post } = await useFetch<Post>(
() => `/api/posts/${route.params.slug}`,
)
useSeoMeta({
title: () => post.value?.title ?? 'Blog',
description: () => post.value?.excerpt ?? '',
ogTitle: () => post.value?.title ?? 'Blog',
ogDescription: () => post.value?.excerpt ?? '',
ogImage: () => `${site}${post.value?.image ?? '/og-default.png'}`,
ogUrl: () => `${site}${route.path}`,
twitterCard: 'summary_large_image',
})
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<p>{{ post.excerpt }}</p>
</article>
</template>
<style scoped>
article {
max-width: 42rem;
}
</style>When to use it
- Giving each article page its own title and description instead of one title for the whole app.
- Fixing link previews that show the site name and no image when a URL is pasted into chat.
- Producing a sitemap from the same data that renders the pages, so it cannot drift.
More examples
The same tags in longhand
`useSeoMeta` is a typed wrapper over this. Seeing the long form matters for reading other people's code, and for the detail it exposes: Open Graph tags use `property`, Twitter tags use `name`, and getting that backwards produces a tag no crawler reads. `useHead` is still the API you need for `link` and `script` entries, which `useSeoMeta` does not cover.
// Equivalent to the useSeoMeta call above, written with useHead.
useHead({
title: () => post.value?.title ?? 'Blog',
meta: [
{ name: 'description', content: () => post.value?.excerpt ?? '' },
{ property: 'og:title', content: () => post.value?.title ?? 'Blog' },
{ property: 'og:description', content: () => post.value?.excerpt ?? '' },
{ name: 'twitter:card', content: 'summary_large_image' },
],
})Structured data for an article
JSON-LD is a script tag whose contents are data, not code. Serialise the object rather than writing the JSON by hand so quoting stays correct. Because this runs in setup, the tag is in the server-rendered HTML where a crawler can read it. Validate the result against a structured data testing tool: a malformed `@type` is ignored silently, with no error anywhere.
// app/pages/blog/[slug].vue, below the useSeoMeta call
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.value?.title ?? '',
datePublished: post.value?.publishedAt ?? '',
author: { '@type': 'Organization', name: 'Example' },
}),
},
],
})A sitemap as a server route
`server/routes/` has no `/api` prefix, so this file answers `/sitemap.xml` directly. Set the content type explicitly or the host may serve it as HTML. Add the path to `nitro.prerender.routes` in `nuxt.config.ts` and a prerendered build writes it out as a file, which is what a static deployment needs.
// server/routes/sitemap.xml.ts
export default defineEventHandler(async (event) => {
const posts = await $fetch<{ slug: string }[]>('/api/posts')
const site = 'https://example.com'
const urls = posts
.map((post) => `<url><loc>${site}/blog/${post.slug}</loc></url>`)
.join('')
setHeader(event, 'content-type', 'application/xml')
return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`
})
Discussion