Nuxt renders pages on the server, so a crawler, a link preview bot or a browser with JavaScript turned off receives finished HTML. That only pays off if the HTML carries the right <title>, <meta> and <link> tags, and if a missing page answers with a 404 instead of a cheerful 200.
The example is a small bakery blog. By the end, each post page describes itself to search engines and link previews, and a post that does not exist returns a real 404 with a custom error page. The project uses the Nuxt 4 layout, so app.vue, pages/ and error.vue live inside app/.
Set site-wide defaults in app.vue
Head tags in Nuxt are managed by Unhead 2 through two composables:
useHead()takes the raw structure of the document head:title,titleTemplate,meta,link,script,htmlAttrsandbodyAttrs.useSeoMeta()takes a flat, typed object of meta tags:ogTitlebecomes<meta property="og:title">, and a typo is a type error.
Use useSeoMeta for meta tags and useHead for everything else. The defaults go in app.vue, which wraps every page:
<script setup lang="ts">
useHead({
titleTemplate: (chunk) => (chunk ? `${chunk} · Fern Bakery` : 'Fern Bakery'),
htmlAttrs: { lang: 'en' },
link: [{ rel: 'icon', href: '/favicon.ico' }],
})
useSeoMeta({
ogSiteName: 'Fern Bakery',
twitterCard: 'summary_large_image',
})
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
titleTemplate is a function that receives the page's title (the "chunk") and returns the full <title>. A page that sets title: 'Sourdough' renders Sourdough · Fern Bakery; the home page, which sets no title, renders the site name alone.
The string form '%s · Fern Bakery' has no such fallback: a page without a title gets the separator with nothing in front of it. It is also the only form you can put in nuxt.config.ts under app.head, because the config is serialised. Keep the function in app.vue.
Describe each page with useSeoMeta()
A blog post page loads its content and then describes itself. The values depend on data that arrives asynchronously, so they are passed as getters, small functions that Unhead calls again whenever the data changes:
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
useSeoMeta({
title: () => post.value?.title ?? 'Post',
description: () => post.value?.summary,
ogTitle: () => post.value?.title,
ogDescription: () => post.value?.summary,
ogImage: () => post.value?.cover,
ogType: 'article',
})
</script>
What each tag is for:
titleis the chunk the template inapp.vuewraps.descriptionis the text a search result often shows under the link, so keep it to one or two sentences of the post's actual content.- The
ogtags are the Open Graph protocol, which chat apps and social networks read to draw a link card.ogImagemust be an absolute URL,https://fernbakery.example/covers/sourdough.jpg, because the bot fetching it is not on your site. A 1200 by 630 pixel image fits the cards of the common networks. twitterCard: 'summary_large_image', set once inapp.vue, asks for the large card layout.
A value that is static can be a plain string, as ogType is.
For tags that never change, the Nuxt docs suggest wrapping the call in if (import.meta.server) to skip the work in the browser, since crawlers read the first page load. A client-side navigation then never updates those tags, so keep title, which visitors see in the browser tab, and every tag with a getter outside the condition.
Add a canonical link with useHead()
A canonical link tells search engines which URL is the real one when the same page is reachable at several, for example with and without a tracking parameter. It is a <link>, so it goes through useHead(). This composable builds it from the current URL:
export function useCanonical() {
const route = useRoute()
const { origin } = useRequestURL()
useHead({
link: [{ rel: 'canonical', href: () => origin + route.path }],
})
}
useRequestURL() returns the URL of the current request on the server and window.location in the browser, so the composable works in both places. route.path excludes the query string on purpose. Call useCanonical() from each page, or from a layout to cover all of them.
The same useHead call accepts script entries. A tagPosition: 'bodyClose' on a script moves it to the end of <body>, so it does not block the first render.
Build a custom error page in app/error.vue
When a request fails on the server, or a page throws a fatal error, Nuxt renders app/error.vue instead of the page. It is not a route: it sits next to app.vue rather than in pages/, it should not use definePageMeta, and it receives the error as its only prop. On the server it is rendered as a separate page load, so your route middleware runs again. In a middleware, useError() tells you whether an error is being handled.
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{ error: NuxtError }>()
const notFound = computed(() => props.error.statusCode === 404)
function goHome() {
clearError({ redirect: '/' })
}
useSeoMeta({
title: () => (notFound.value ? 'Page not found' : 'Something went wrong'),
robots: 'noindex',
})
</script>
<template>
<NuxtLayout>
<main>
<h1>{{ notFound ? 'We could not find that page' : 'Something went wrong' }}</h1>
<p v-if="!notFound">{{ error.statusMessage }}</p>
<button type="button" @click="goHome">Back to the home page</button>
</main>
</NuxtLayout>
</template>
statusCode is the error's HTTP status, and it is the branch to write copy on: a 404 is the visitor's problem to solve with a link, a 500 is yours and deserves an apology.
clearError({ redirect: '/' }) resets the error state and navigates; without the redirect the app would stay on the broken URL. robots: 'noindex' keeps error pages out of search results. Wrapping the content in <NuxtLayout> gives the page the site's header and footer, so a lost visitor still has the navigation.
Return a real 404 from a page
The blog page above renders fine for /blog/does-not-exist: the API has no such post, post.value is undefined, and the page shows an empty article with a 200 status. That is a soft 404, and search engines may index the empty page as if it were real. Throw an error instead:
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
}
</script>
On a direct visit, the server stops rendering, answers with status 404 and sends error.vue. During a client-side navigation there is no HTTP response to set a status on; fatal: true is what makes Nuxt show the full-screen error page instead of leaving the error in the console. Place the check after the fetch and before any useSeoMeta call that assumes a post.
Two kinds of error look alike and are not. A page error, thrown in <script setup> or in middleware, renders error.vue. A server route error, thrown inside server/api/posts/[slug].ts with the same createError, is returned to the caller as JSON. useFetch puts it in its error ref and the page decides what to do with it, which is why the check above lives in the page.
For a widget that may fail without taking the page down, wrap it in <NuxtErrorBoundary> and render a fallback in its #error slot. It only catches errors in the browser: an error thrown while the server renders the page still shows error.vue.
Check the HTML a crawler sees
Bots do not run your JavaScript the way a browser does, so test the HTML, not the rendered page. With the dev server running:
curl -s http://localhost:3000/blog/sourdough | grep -oE '<title>[^<]*</title>|<meta [^>]*>|<link [^>]*rel="canonical"[^>]*>'
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/blog/does-not-exist
Nuxt 4 writes the whole <head> on one line, so the first command uses grep -o to print each matching tag on its own line. It should list the title, the description, every og: tag and the canonical link with their final values. The second should print 404.
Repeat both against the deployed site, because ogImage URLs and the canonical origin only become real there. If the canonical link shows an internal host name, your server is reading the Host header a proxy sent. When the proxy sets X-Forwarded-Host, change the call in useCanonical() to useRequestURL({ xForwardedHost: true }) so it reads that header instead.
Some networks publish a link-preview debugger, such as Facebook's Sharing Debugger and LinkedIn's Post Inspector: paste a production URL into one before announcing a post.
Add sitemaps and share images with @nuxtjs/seo
Everything above is built into Nuxt. The @nuxtjs/seo module, at version 3.2, bundles the parts that are not: a generated sitemap.xml, a robots.txt, Open Graph images rendered from a Vue component, schema.org structured data, a link checker, and a shared site configuration. Install it with npx nuxt module add @nuxtjs/seo and give the site configuration your site's address and name:
export default defineNuxtConfig({
modules: ['@nuxtjs/seo'],
site: {
url: 'https://fernbakery.example',
name: 'Fern Bakery',
},
})
NUXT_SITE_URL and NUXT_SITE_NAME override those per environment, which keeps a preview deployment from advertising the production domain. Start with useSeoMeta alone; it is small and it teaches you what the tags mean. Add the module when you need a sitemap or generated share images.