404 errors don't hurt SEO. They're expected—deleted products, outdated links, user typos all create legitimate 404s. Google ignores them.
Soft 404s hurt SEO. A soft 404 returns 200 OK status but shows "page not found" content. Google excludes these from search results and wastes your crawl budget recrawling pages it thinks exist.
Nuxt handles this automatically. Using error.vue and createError() ensures proper status codes for both SSR and SSG.
Create error.vue in your project root:
<script setup>
const props = defineProps({
error: Object
})
useHead({
title: props.error?.statusCode === 404 ? '404 - Page Not Found' : 'Error',
meta: [
{ name: 'robots', content: 'noindex' }
]
})
</script>
<template>
<div class="error-page">
<template v-if="error?.statusCode === 404">
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<NuxtLink to="/">
Go home
</NuxtLink>
</template>
<template v-else>
<h1>An error occurred</h1>
<p>{{ error?.message }}</p>
</template>
</div>
</template>
Throw 404 errors in pages when content doesn't exist:
<script setup>
const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.id}`)
if (!product.value) {
throw createError({
statusCode: 404,
statusMessage: 'Product not found'
})
}
useSeoMeta({
title: product.value.name,
description: product.value.description
})
</script>
Nuxt returns proper 404 status code for SSR and static generation automatically.
Soft 404 detection happens when Google sees content that looks like an error page but receives 200 OK status (Google Search Central).
Common triggers:
Google Search Console flags soft 404s in the "Page Indexing" report. Fix by returning proper 404 status code.
Nuxt's file-based routing handles most route validation automatically. For dynamic routes, check data existence:
<script setup>
const route = useRoute()
const { data: post } = await useAsyncData(`post-${route.params.slug}`, () =>
queryContent(`/blog/${route.params.slug}`).findOne())
if (!post.value) {
throw createError({
statusCode: 404,
statusMessage: 'Post not found'
})
}
useSeoMeta({
title: post.value.title,
description: post.value.description
})
</script>
<template>
<article>
<h1>{{ post.title }}</h1>
<ContentRenderer :value="post" />
</article>
</template>
The createError() function triggers Nuxt's error handling, renders error.vue, and returns proper HTTP status code.
Check data existence before rendering:
<script setup>
const route = useRoute()
const { data: product } = await useFetch(`/api/products/${route.params.id}`)
if (!product.value) {
throw createError({
statusCode: 404,
statusMessage: 'Product not found'
})
}
// Product exists, continue with meta tags
useSeoMeta({
title: product.value.name,
description: product.value.description,
ogImage: product.value.image
})
</script>
<template>
<div class="product">
<h1>{{ product.name }}</h1>
<p>{{ product.description }}</p>
</div>
</template>
For server routes, use setResponseStatus:
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const product = await findProduct(id)
if (!product) {
setResponseStatus(event, 404)
return { error: 'Product not found' }
}
return product
})
404 Not Found - Resource doesn't exist, might never have existed, might come back:
410 Gone - Resource existed, now permanently removed:
Google treats both similarly for indexing—removes from search results. 410 signals faster removal but rarely needed. Use 404 for most cases.
To return 410:
<script setup>
throw createError({
statusCode: 410,
statusMessage: 'This product has been discontinued'
})
</script>
Good 404 pages keep users on your site:
<script setup>
const props = defineProps({
error: Object
})
const is404 = computed(() => props.error?.statusCode === 404)
useHead({
title: is404.value ? '404 - Page Not Found' : 'Error',
meta: [
{ name: 'robots', content: 'noindex' }
]
})
</script>
<template>
<div class="not-found">
<template v-if="is404">
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist or has moved.</p>
<SearchBox />
<nav>
<h2>Popular Pages:</h2>
<ul>
<li>
<NuxtLink to="/products">
Products
</NuxtLink>
</li>
<li>
<NuxtLink to="/blog">
Blog
</NuxtLink>
</li>
<li>
<NuxtLink to="/support">
Support
</NuxtLink>
</li>
</ul>
</nav>
<NuxtLink to="/">
Go to Homepage
</NuxtLink>
</template>
<template v-else>
<h1>An error occurred</h1>
<p>{{ error?.message }}</p>
<NuxtLink to="/">
Go to Homepage
</NuxtLink>
</template>
</div>
</template>
Don't:
Do:
404 errors have minimal crawl budget impact (Google Search Central). Google expects them. Soft 404s waste crawl budget because Google recrawls pages thinking content exists.
Large sites (10,000+ pages) should:
Don't worry about occasional 404s from external links or user typos.
Use 301 redirect in nuxt.config.ts:
export default defineNuxtConfig({
routeRules: {
'/old-product': { redirect: { to: '/new-product', statusCode: 301 } },
'/old-category/**': { redirect: { to: '/new-category/**', statusCode: 301 } }
}
})
Or in server middleware:
export default defineEventHandler((event) => {
if (event.path === '/old-product') {
return sendRedirect(event, '/new-product', 301)
}
})
Return 404 or 410. If similar content exists, redirect to relevant category:
export default defineNuxtConfig({
routeRules: {
// ✅ Good - redirect to relevant category
'/discontinued-product': { redirect: { to: '/products/similar-items', statusCode: 301 } },
// ✅ Also good - let Nuxt return 404 naturally for non-existent routes
}
})
Verify proper status codes before deploying:
Browser DevTools:
404 not 200Command line:
curl -I https://example.com/fake-page
# Output should show:
# HTTP/1.1 404 Not Found
Google Search Console:
Lighthouse: Run Lighthouse audit, check "Crawling and Indexing" section for status code issues.
Creates soft 404 risk. Google may ignore redirects to irrelevant pages.
// ❌ Bad - mass redirect to homepage
export default defineNuxtConfig({
routeRules: {
'/**': { redirect: '/' } // Don't do this
}
})
Only redirect if replacement content is relevant. Otherwise let Nuxt return proper 404.
Rendering error content without throwing error returns 200 OK:
<!-- ❌ Bad - returns 200 status -->
<template>
<div v-if="!product">
<h1>404 Not Found</h1>
</div>
</template>
<!-- ✅ Good - returns 404 status -->
<script setup>
const { data: product } = await useFetch('/api/product')
if (!product.value) {
throw createError({ statusCode: 404 })
}
</script>
If 404 page accidentally returns 200 OK, noindex prevents indexing:
<script setup>
useHead({
meta: [
{ name: 'robots', content: 'noindex' }
]
})
</script>
Safety net, not primary solution. Use createError() for proper status codes.
Repeated 404s to same path indicate broken internal links or outdated external links. Check Search Console "Not Found" report monthly, fix internal links immediately.
For custom 404 handling beyond error.vue, use catch-all routes:
<script setup>
// This catches all unmatched routes
const route = useRoute()
// Try to find content
const { data: page } = await useAsyncData(`page-${route.path}`, () =>
queryContent(route.path).findOne())
if (!page.value) {
throw createError({
statusCode: 404,
statusMessage: 'Page not found'
})
}
useSeoMeta({
title: page.value.title,
description: page.value.description
})
</script>
<template>
<ContentRenderer :value="page" />
</template>
This pattern works for CMS-driven sites where routes come from database/content.
Hreflang & i18n
Set hreflang tags in Nuxt to tell search engines which language version to show users. Avoid duplicate content penalties across multilingual sites.
Dynamic Routes
How to use Nuxt's file-based dynamic routes, set per-route meta tags, and avoid duplicate content issues with URL parameters.