SEO, meta and error pages
Give every page a title, description, canonical link and social preview with useSeoMeta(), show one error page with the right status, and keep a failing part of a page from taking the rest down.
What you'll learn
When you finish, you can do these.- 1Set each page's title and description with useSeoMeta()
- 2Give a shared post a picture in its link preview
- 3Show one error page for a missing post
- 4Keep failing comments from taking the article down
The Corner Shop Journal, the blog of the small online shop this track builds, has four problems. Search results show one title for every post. A post shared in a chat shows up as a bare link, with no picture. An old link to a deleted post opens a blank page. And when the comments fail to load, the article goes down with them.
The Code tab holds the Journal as a small Nuxt app. By the end, each post has its own title and preview picture, a missing post gets one error page, and failing comments stay in their own box. You need one idea from the lesson SSR and hydration: the first page a visitor opens is rendered on the server, and each page they reach by a link after that is rendered in the browser.
Titles, descriptions and link previews
Each post needs its own title and description for search results, and a picture for link previews. All three are tags in the page's <head>, the part of the HTML that describes the page without showing on it. Search engines and chat apps read it, usually without running JavaScript, and Nuxt renders it on the server.
The post page, app/pages/blog/[slug].vue in the Code tab, serves every post: [slug] stands for the end of its address, such as hello-nuxt. It loads the post with useFetch(), Nuxt's way to load a page's data, and hands its title and excerpt, a one-line summary, to useSeoMeta(), which writes those tags from one object:
<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?.excerpt,
})
</script>
Each value is a getter, () => …, a function that returns the value, so the tag follows the data. A plain value is read once, when setup runs. With lazy: true, setup does not wait for the post, so a plain value is read while the post is still empty: even the server HTML gets no post title.
The site name belongs in every title, so the root component, app/app.vue, adds it once. useHead(), which sets any head tag, takes a titleTemplate function that receives each page's title and returns the site name alone when there is none:
<script setup lang="ts">
useHead({
titleTemplate: title => (title ? `${title} · Corner Shop Journal` : 'Corner Shop Journal'),
})
</script>
Open the Hello Nuxt post, and the browser tab reads Hello Nuxt · Corner Shop Journal.
A link preview reads the Open Graph tags og:title, og:description and og:image, its picture. In useSeoMeta() they are ogTitle, ogDescription and ogImage.
The chat app downloads the picture from its own servers, so og:image needs a full address, not just the post's image path, such as /og/hello-nuxt.png. The post page builds it from siteUrl, a runtime config setting in nuxt.config.ts that each host changes with NUXT_PUBLIC_SITE_URL (A Nuxt project tour):
<script setup lang="ts">
const route = useRoute()
const { public: { siteUrl } } = useRuntimeConfig()
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: () => `${siteUrl}${post.value?.image}`,
twitterCard: 'summary_large_image',
})
</script>
For Hello Nuxt, og:image now reads http://localhost:3000/og/hello-nuxt.png in development, and twitterCard asks X for a large picture.
A canonical link tells search engines which URL is the real one when a post is reachable at several, such as /blog/hello-nuxt?utm_source=newsletter. It needs the full address too, and the post page adds it with useHead():
useHead({
link: [{ rel: 'canonical', href: () => `${siteUrl}/blog/${route.params.slug}` }],
})
Every address of the post now names http://localhost:3000/blog/hello-nuxt as the real one.
One error page for the whole app
A missing post should get one clear error page with a 404 status, the code that says a page does not exist. The server route that sends the posts answers an unknown one with a 404, and the post page passes it on with createError(), which builds an error with a status (Server routes):
const { data: post, error } = await useFetch(() => `/api/posts/${route.params.slug}`)
if (error.value) {
throw createError({ status: error.value.status, statusText: error.value.statusText, fatal: true })
}
On the first load, rendered on the server, any error thrown this way replaces the page with your error page, app/error.vue, and sends its status, so search engines see a real 404. After that, links render pages in the browser, where only an error with fatal: true shows the error page. Without it, a click on an old link leaves the space under the header blank. Every page can be reached both ways, so pass fatal: true.
app/error.vue, one file for the whole app, receives the error as a prop and computes notFound, true for a 404:
<template>
<main>
<h1>{{ notFound ? 'Page not found' : 'Something went wrong' }}</h1>
<p>{{ error.statusText }}</p>
<button @click="clearError({ redirect: '/' })">Back to the journal</button>
</main>
</template>
A missing post shows Page not found and a Back to the journal button, whose clearError({ redirect: '/' }) clears the error and goes home.
A fallback for one part of the page
Comments are not worth losing the article over, so they get a fallback. They live in their own component under the article, PostComments.vue, whose Load more button fetches more comments.
<NuxtErrorBoundary> wraps part of a page: when something inside it throws, it shows its #error slot, a template for the failure, in that part's place.
createError({ fatal: true })The whole page becomes app/error.vue, with the error's status. Use it when the page cannot exist: a missing post, a failed load of its main data.
<NuxtErrorBoundary>Only the wrapped part shows the #error slot, and the article around it keeps working. Use it for a widget that can fail after the page has loaded.
In the post page, the slot receives the error, named commentsError here, and a clearError function that shows the comments again:
<NuxtErrorBoundary>
<PostComments :slug="String(route.params.slug)" />
<template #error="{ error: commentsError, clearError }">
<p role="alert">
Comments failed: {{ commentsError.message }}
<button @click="clearError">Try again</button>
</p>
</template>
</NuxtErrorBoundary>
When Load more fails, only the comments block changes to Comments failed and a Try again button.
The boundary catches only errors in the browser, like this one. During the server render it does nothing, and an error there fails the whole page. So PostComments.vue loads its first comments with useFetch(), which hands back an error ref instead of throwing.
What goes wrong
A relative og:image, a path with no site address, gives previews no picture.
ogImage: () => post.value?.image,
View Source shows <meta property="og:image" content="/og/hello-nuxt.png">. The chat app fetches the image from its own servers, where that path leads nowhere. Join the path to the site's address:
ogImage: () => `${siteUrl}${post.value?.image}`,
The preview gets its picture again.
A server-side error inside the boundary still fails the whole page. Here PostComments.vue loads its comments with $fetch, which throws when the request fails:
<script setup lang="ts">
const props = defineProps<{ slug: string }>()
const comments = await $fetch(`/api/posts/${props.slug}/comments`)
</script>
While the comments route is down, every post opens on the error page instead of the article. The boundary around the comments does not help: the error happened during the server render. Let useFetch return the error instead:
<script setup lang="ts">
const props = defineProps<{ slug: string }>()
const { data: comments, error } = await useFetch(`/api/posts/${props.slug}/comments`)
</script>
The article renders, and the comments block says "Comments are unavailable right now."
What to remember
useSeoMeta()with getters puts each page's title and description in the server's HTML.titleTemplateinapp.vueadds the site name.- Link previews read the
og:tags.og:imageand the canonical link need full URLs, built from a configured site address. - On the server, a thrown error renders
app/error.vuewith its status. In the browser onlyfatal: truedoes. <NuxtErrorBoundary>catches errors in the browser only, so load data during the server render withuseFetchand show itserror.
In your project
Do these in a real project. Each one says how to confirm it worked.- View Source on
/blog/hello-nuxt: the title is Hello Nuxt · Corner Shop Journal,og:imagestarts withhttp://localhost:3000/og/, and a canonical link is there. - Open
/blog/spring-saledirectly: Page not found, and the Network tab shows a 404. Deletefatal: trueand click Spring sale on the home page: the space under the header stays blank, with the error only in the console. Putfatal: trueback. - In
comments.get.ts, uncomment thethrow, open a post and click Load more: only the comments block changes to Comments failed with a Try again button. Changepage > 1topage > 0and reload: the article still renders, with "Comments are unavailable right now.", becauseuseFetchreturned the error.