---
title: "404 Pages and SEO in Nuxt"
description: "404s are normal, but soft 404s get pages deindexed. Return real HTTP status codes in Nuxt with error.vue and createError()."
canonical_url: "https://nuxtseo.com/learn-seo/nuxt/routes-and-rendering/404-pages"
last_updated: "2026-07-16"
---

<key-takeaways>

- 404 errors don't hurt SEO; soft 404s do, because a `200 OK` status on an error page tells Google the URL is still a real, indexable page
- Nuxt's `error.vue` and `createError()` return the correct HTTP status code automatically in both SSR and static builds
- Google treats 404 and 410 as the same signal for indexing, so pick whichever your app returns naturally rather than engineering one specifically

</key-takeaways>

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#when-crawler-control-matters) 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)).

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 this hurts your 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**: you can't redirect or canonicalize a page that officially doesn't exist

## 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}`, () =>
  queryCollection('blog').path(`/blog/${route.params.slug}`).first())

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 them the same for indexing](https://developers.google.com/search/docs/crawling-indexing/http-network-errors): both statuses tell the crawler the content doesn't exist, and previously indexed URLs get dropped from search results either way. Use 410 only when you want to state explicitly that the content is gone for good; 404 is fine for everything else.

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

A real 404 costs you little: Google's [crawling frequency for a URL gradually decreases](https://developers.google.com/search/docs/crawling-indexing/http-network-errors) once it keeps returning 404, and a newly discovered 404 never enters the indexing pipeline at all. Soft 404s are the expensive ones: Google keeps recrawling them because the `200 OK` status says the content still 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.

Repeated 404s to the same path indicate broken internal links or outdated external links. Check Search Console's "Not Found" report monthly and 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}`, () =>
  queryCollection('pages').path(route.path).first())

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.

## Checklist

<checklist id="nuxt-404-pages">

- `error.vue` throws through `createError()` so real 404s return status 404, not 200
- Dynamic routes (`pages/products/[id].vue`) check for missing data and call `createError()` before rendering
- No route redirects blanket-forward to the homepage; each 404 either stays a 404 or gets a targeted 301
- `noindex` on `error.vue` is a fallback only, not a substitute for the correct status code
- `curl -I` against a deleted URL returns `404`, not `200`
- Search Console's Page Indexing report shows no soft 404s

</checklist>
