404 Pages and SEO in Nuxt

404 errors don't hurt SEO, but soft 404s do. Learn proper HTTP status codes, custom 404 design, and crawl budget optimization for Nuxt applications.
Harlan WiltonHarlan Wilton8 mins read Published

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.

Quick Setup

Create error.vue in your project root:

error.vue
<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:

pages/products/[id].vue
<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 Errors Explained

Soft 404 detection happens when Google sees content that looks like an error page but receives 200 OK status (Google Search Central).

Common triggers:

  • "Page not found" in title or heading
  • Minimal content (under ~200 words)
  • Redirecting all 404s to homepage
  • Empty page body with "coming soon" message
  • Generic error messages without meaningful content

Google Search Console flags soft 404s in the "Page Indexing" report. Fix by returning proper 404 status code.

Why Soft 404s Hurt SEO

  1. Wasted crawl budget - Google recrawls pages thinking they exist, leaving less budget for real pages
  2. Index bloat - Search Console shows thousands of indexed URLs that don't exist
  3. Ranking signals confusion - Google doesn't know if content moved or disappeared
  4. No link equity transfer - Can't redirect or canonicalize non-existent pages properly

Checking Routes and Throwing Errors

Nuxt's file-based routing handles most route validation automatically. For dynamic routes, check data existence:

pages/blog/[slug].vue
<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.

Dynamic Routes Considerations

Check data existence before rendering:

pages/products/[id].vue
<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:

server/api/products/[id].get.ts
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 vs 410 Status Codes

404 Not Found - Resource doesn't exist, might never have existed, might come back:

  • User typos
  • Outdated external links
  • Deleted products that might return to inventory
  • Seasonal content (holiday pages)

410 Gone - Resource existed, now permanently removed:

  • Discontinued products
  • Deleted blog posts (no redirect target)
  • Expired promotions
  • Intentionally removed content

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:

pages/discontinued/[id].vue
<script setup>
throw createError({
  statusCode: 410,
  statusMessage: 'This product has been discontinued'
})
</script>

Custom 404 Page Design

Good 404 pages keep users on your site:

error.vue
<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:

  • Redirect all 404s to homepage (soft 404 risk)
  • Auto-redirect after countdown (bad UX)
  • Show only "404" with no explanation
  • Display technical error messages

Do:

  • Explain what happened clearly
  • Provide search functionality
  • Link to popular/relevant pages
  • Match site design (keeps users oriented)
  • Include contact option for reporting broken links

Crawl Budget Impact

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:

  • Monitor 404 rates in Search Console
  • Fix internal links pointing to 404s
  • Remove 404 URLs from sitemap
  • Use 301 redirects for high-value deleted pages with relevant replacements

Don't worry about occasional 404s from external links or user typos.

Handling 404s for Deleted Content

Content Moved

Use 301 redirect in nuxt.config.ts:

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:

server/middleware/redirects.ts
export default defineEventHandler((event) => {
  if (event.path === '/old-product') {
    return sendRedirect(event, '/new-product', 301)
  }
})

Content Permanently Removed

Return 404 or 410. If similar content exists, redirect to relevant category:

nuxt.config.ts
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
  }
})

Testing 404 Responses

Verify proper status codes before deploying:

Browser DevTools:

  1. Open Network tab
  2. Navigate to non-existent URL
  3. Check status code in response headers
  4. Should show 404 not 200

Command line:

curl -I https://example.com/fake-page

# Output should show:
# HTTP/1.1 404 Not Found

Google Search Console:

  1. Use URL Inspection tool
  2. Enter 404 URL
  3. "Request indexing"
  4. Check if Google recognizes 404 status
  5. Monitor "Page Indexing" report for soft 404 flags

Lighthouse: Run Lighthouse audit, check "Crawling and Indexing" section for status code issues.

Common Mistakes

Redirecting All 404s to Homepage

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.

Not Using createError()

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>

Forgetting noindex Meta Tag

If 404 page accidentally returns 200 OK, noindex prevents indexing:

error.vue
<script setup>
useHead({
  meta: [
    { name: 'robots', content: 'noindex' }
  ]
})
</script>

Safety net, not primary solution. Use createError() for proper status codes.

Not Monitoring 404 Patterns

Repeated 404s to same path indicate broken internal links or outdated external links. Check Search Console "Not Found" report monthly, fix internal links immediately.

Catch-All Routes

For custom 404 handling beyond error.vue, use catch-all routes:

pages/[...slug].vue
<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.