---
title: "404 Pages and SEO in Nuxt"
description: "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."
canonical_url: "https://nuxtseo.com/learn-seo/nuxt/routes-and-rendering/404-pages"
last_updated: "2025-12-17"
---

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](/learn-seo/nuxt/controlling-crawlers#crawler-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:

```vue [error.vue]
<script setup lang="ts">
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:

```vue [pages/products/[id].vue]
<script setup lang="ts">
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](https://developers.google.com/search/docs/crawling-indexing/http-network-errors#soft-404-errors)).

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:

```vue [pages/blog/[slug].vue]
<script setup lang="ts">
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:

```vue [pages/products/[id].vue]
<script setup lang="ts">
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`:

```ts [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:

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

## Custom 404 Page Design

Good 404 pages keep users on your site:

```vue [error.vue]
<script setup lang="ts">
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](https://developers.google.com/search/docs/crawling-indexing/http-network-errors)). 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](/learn-seo/nuxt/controlling-crawlers/sitemaps)
- 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](/learn-seo/nuxt/controlling-crawlers/redirects) in `nuxt.config.ts`:

```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:

```ts [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:

```ts [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:**

```bash
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](/learn-seo/nuxt/controlling-crawlers/redirects) to irrelevant pages.

```ts
// ❌ 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`:

```vue
<!-- ❌ Bad - returns 200 status -->
<template>
  <div v-if="!product">
    <h1>404 Not Found</h1>
  </div>
</template>
```

```vue
<!-- ✅ Good - returns 404 status -->
<script setup lang="ts">
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`](/learn-seo/nuxt/controlling-crawlers/meta-tags) prevents indexing:

```vue [error.vue]
<script setup lang="ts">
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:

```vue [pages/[...slug].vue]
<script setup lang="ts">
// 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.
