Index of every page on this site: llms.txt

404 Pages and SEO in Nuxt

Return missing-content HTTP errors in Nuxt and diagnose detected soft 404s that Google excludes from search.
Harlan Wilton8 mins read Published Updated
What you'll learn
  • Legitimate missing URLs can return 404. Google can detect error-like content with a successful response as a soft 404 and exclude it
  • Nuxt's error.vue and createError() support server error responses; static hosting needs its own correct 404 behavior
  • Google treats 404 and 410 as the same signal for indexing, so pick whichever your app returns naturally rather than engineering one specifically

Legitimate missing URLs can return 404: deleted products and user typos are common examples. Repair broken internal links to pages readers still need. Google removes indexed URLs that consistently return these statuses.

A detected soft 404 serves error-like or empty content despite a successful HTTP response. Google excludes detected soft 404s from search results. Diagnose the response and content together.

During SSR, use Nuxt error handling and check the response status. For static output, configure and test the host's missing-route response. An error component alone does not guarantee the HTTP status.

Quick Setup

For Nuxt 4's default directory structure, create app/error.vue:

app/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>

This initial-load excerpt requires an app-owned product endpoint returning a product or an HTTP error. Handle a successful empty result as missing data:

pages/products/[id].vue
<script setup lang="ts">
const route = useRoute()
const { data: product, error } = await useFetch(`/api/products/${route.params.id}`)

if (error.value)
  throw error.value
if (!product.value) {
  throw createError({
    statusCode: 404,
    statusMessage: 'Product not found'
  })
}

useSeoMeta({
  title: product.value.name,
  description: product.value.description
})
</script>

Throwing the error during SSR can produce a 404 response. For static generation, test the deployed host’s missing-route behavior separately.

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
  • An empty or error-like page served with a successful status
  • 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.

A soft 404 can exclude a page you intended to publish. If content moved, use a relevant permanent redirect. If it disappeared without a replacement, return 404 or 410.

Checking Routes and Throwing Errors

This initial-load Content excerpt requires a configured blog collection with title and description fields. Collection failures propagate; an absent document produces 404:

pages/blog/[slug].vue
<script setup lang="ts">
const route = useRoute()
const { data: post, error } = await useAsyncData(`post-${route.params.slug}`, () =>
  queryCollection('blog').path(`/blog/${route.params.slug}`).first())

if (error.value)
  throw error.value
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>

Throwing createError() during server-side page setup invokes Nuxt error handling. Check the resulting status. error.vue displays the error; it does not originate it.

Dynamic Routes Considerations

This initial-load excerpt requires an app-owned product endpoint supplying name, description, and image. Check upstream failures before absent data:

pages/products/[id].vue
<script setup lang="ts">
const route = useRoute()

const { data: product, error } = await useFetch(`/api/products/${route.params.id}`)

if (error.value)
  throw error.value
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>

Your server endpoint must distinguish missing data from infrastructure failures. Throw an HTTP 404 for an absent product. Propagate upstream failures instead of returning an error-shaped object as product data.

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

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:

app/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 once it keeps returning 404, and a newly discovered 404 never enters the indexing pipeline at all. Soft 404 detection considers both the successful status and error-like content. Inspect the cause instead of inferring a crawl schedule.

For maintained sites:

  • 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 } }
  }
})

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. Run a live inspection to check the response
  4. Compare the reported status with your direct HTTP check
  5. Monitor the 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 lang="ts">
const { data: product, error } = await useFetch('/api/product')

if (error.value)
  throw error.value
if (!product.value) {
  throw createError({ statusCode: 404 })
}
</script>

Forgetting noindex Meta Tag

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

app/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 can come from broken links, typos, or automated probes. Inspect referrers and logs before identifying the cause. Repair broken internal links to required destinations.

Catch-All Routes

A catch-all can look up an app-owned Content collection before throwing a missing-page error:

pages/[...slug].vue
<script setup lang="ts">
// This catches all unmatched routes
const route = useRoute()

// Try to find content
const { data: page, error } = await useAsyncData(`page-${route.path}`, () =>
  queryCollection('pages').path(route.path).first())

if (error.value)
  throw error.value
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 initial-load integration excerpt requires a configured pages Content collection with title and description fields. Handle route changes separately if the component remains mounted.

Checklist

Checklist
  • The originating page or server throws createError() for missing data; error.vue displays that error
  • 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
Was this page helpful?