404 Pages and SEO in Vue · Nuxt SEO

-
-
-
-

[1.4K](https://github.com/harlan-zw/nuxt-seo)

[Nuxt SEO on GitHub](https://github.com/harlan-zw/nuxt-seo)

Learn SEO

Master search optimization

Nuxt

 Vue

-
-
-
-
-
-
-

-
-
-
-
-
-
-

-
-
-

-
-
-
-
-
-
-
-
-
-
-

-
-
-

-
-
-
-
-
-
-
-
-

1.
2.
3.
4.
5.

# 404 Pages and SEO in Vue

404 errors don't hurt SEO, but soft 404s do. Learn proper HTTP status codes, custom 404 design, and crawl budget optimization for Vue applications.

[![Harlan Wilton](https://avatars.githubusercontent.com/u/5326365?v=4)Harlan Wilton](https://x.com/harlan-zw)8 mins read Published Dec 17, 2025

What you'll learn

- 404 errors don't hurt SEO, but soft 404s do (200 status with error content)
- SSR required. SPAs return 200 for all routes and render errors client-side
- Return proper 404 HTTP status code from the server, not just client-side error pages

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

 recrawling pages it thinks exist.

SSR required. SPAs typically return `200 OK` for all routes and render 404 content client-side. search engines see this as a soft 404.

## [Quick Setup](#quick-setup)

Return proper 404 status codes from your server:

Express

Vite

H3

```
import express from 'express'

const app = express()

app.get('*', (req, res) => {
  // Check if route exists in your Vue Router config
  const routeExists = checkRoute(req.path)

  if (!routeExists) {
    res.status(404).send(render404Page())
    return
  }

  // Normal SSR render
  res.send(renderVueApp(req.path))
})

function render404Page() {
  return \`
    <!DOCTYPE html>
    <html>
      <head>
        <title>404 Not Found</title>
        <meta name="robots" content="noindex">
      </head>
      <body>
        <h1>Page Not Found</h1>
        <p>The page you're looking for doesn't exist.</p>
        <a href="/">Go home</a>
      </body>
    </html>
  \`
}
```

```
// server.js for Vite SSR
import express from 'express'
import { createServer as createViteServer } from 'vite'

const app = express()
const vite = await createViteServer({
  server: { middlewareMode: true }
})

app.use(vite.middlewares)

app.use('*', async (req, res) => {
  const url = req.originalUrl
  const routeExists = await checkRoute(url)

  if (!routeExists) {
    res.status(404)
    const html = await vite.transformIndexHtml(url, render404Template())
    res.send(html)
    return
  }

  // Normal SSR render
  const html = await renderSSR(url)
  res.send(html)
})
```

```
import { defineEventHandler, setResponseStatus } from 'h3'

export default defineEventHandler((event) => {
  const routeExists = checkRoute(event.path)

  if (!routeExists) {
    setResponseStatus(event, 404)
    return render404Page()
  }

  return renderVueApp(event.path)
})
```

Add `noindex` meta tag to prevent 404 pages from appearing in search results if accidentally crawled with wrong status code.

## [Soft 404 Errors Explained](#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](#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

### [Vue SPA Soft 404 Problem](#vue-spa-soft-404-problem)

Vue Router handles routing client-side. Server returns `200 OK` for all paths:

```
// ❌ Bad - SPA returns 200 for /fake-page
app.get('*', (req, res) => {
  res.send(indexHtml) // Always 200 OK
})
```

Vue Router then renders 404 component in browser after JavaScript executes. Google sees `200 OK` response, might see error content, flags as soft 404.

**Solution:** Check route existence server-side before rendering.

## [Checking Routes Server-Side](#checking-routes-server-side)

Match incoming paths against your Vue Router configuration:

```
import { createMemoryHistory, createRouter } from 'vue-router'
import routes from './routes'

function checkRoute(path: string): boolean {
  const router = createRouter({
    history: createMemoryHistory(),
    routes
  })

  const resolved = router.resolve(path)
  return resolved.matched.length > 0
}
```

Integrate with server:

Express

Vite

H3

```
import express from 'express'
import { checkRoute } from './router-check'

const app = express()

app.get('*', (req, res) => {
  if (!checkRoute(req.path)) {
    res.status(404).send(render404Page())
    return
  }

  res.send(renderVueApp(req.path))
})
```

```
import express from 'express'
import { checkRoute } from './router-check'

const app = express()

app.use('*', async (req, res) => {
  if (!checkRoute(req.originalUrl)) {
    res.status(404)
    res.send(await render404SSR())
    return
  }

  res.send(await renderVueApp(req.originalUrl))
})
```

```
import { defineEventHandler, setResponseStatus } from 'h3'
import { checkRoute } from './router-check'

export default defineEventHandler((event) => {
  if (!checkRoute(event.path)) {
    setResponseStatus(event, 404)
    return render404Page()
  }

  return renderVueApp(event.path)
})
```

## [Dynamic Routes Considerations](#dynamic-routes-considerations)

Dynamic routes (`/products/:id`) need data fetching to determine existence:

```
async function checkDynamicRoute(path: string): Promise<boolean> {
  const match = path.match(/^\/products\/([^/]+)$/)
  if (!match)
    return false

  const productId = match[1]
  const exists = await productExists(productId)

  return exists
}

async function productExists(id: string): Promise<boolean> {
  // Query database/API
  const product = await db.products.findById(id)
  return !!product
}
```

Server-side route checking:

Express

Vite

H3

```
app.get('/products/:id', async (req, res) => {
  const exists = await productExists(req.params.id)

  if (!exists) {
    res.status(404).send(render404Page())
    return
  }

  res.send(await renderProductPage(req.params.id))
})
```

```
app.use('/products/:id', async (req, res) => {
  const id = req.params.id
  const exists = await productExists(id)

  if (!exists) {
    res.status(404).send(await render404SSR())
    return
  }

  res.send(await renderProductPage(id))
})
```

```
import { defineEventHandler, getRouterParam, setResponseStatus } from 'h3'

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const exists = await productExists(id)

  if (!exists) {
    setResponseStatus(event, 404)
    return render404Page()
  }

  return renderProductPage(id)
})
```

## [404 vs 410 Status Codes](#_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.

## [Custom 404 Page Design](#custom-404-page-design)

Good 404 pages keep users on your site:

```
<script setup lang="ts">
import { useHead } from '@unhead/vue'

useHead({
  title: '404 - Page Not Found',
  meta: [
    { name: 'robots', content: 'noindex' }
  ]
})
</script>

<template>
  <div class="not-found">
    <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><a href="/products">Products</a></li>
        <li><a href="/blog">Blog</a></li>
        <li><a href="/support">Support</a></li>
      </ul>
    </nav>

    <a href="/">Go to Homepage</a>
  </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](#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
- 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](#handling-404s-for-deleted-content)

### [Content Moved](#content-moved)

Use

 to new location:

Express

Vite

H3

```
app.get('/old-product', (req, res) => {
  res.redirect(301, '/new-product')
})
```

```
app.use((req, res, next) => {
  if (req.path === '/old-product') {
    return res.redirect(301, '/new-product')
  }
  next()
})
```

```
import { defineEventHandler, sendRedirect } from 'h3'

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

### [Content Permanently Removed](#content-permanently-removed)

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

Express

Vite

H3

```
// ✅ Good - redirect to relevant category
app.get('/discontinued-product', (req, res) => {
  res.redirect(301, '/products/similar-items')
})

// ✅ Also good - return 404 if no replacement
app.get('/old-blog-post', (req, res) => {
  res.status(404).send(render404Page())
})
```

```
app.use((req, res, next) => {
  if (req.path === '/discontinued-product') {
    return res.redirect(301, '/products/similar-items')
  }
  if (req.path === '/old-blog-post') {
    res.status(404).send(render404Page())
    return
  }
  next()
})
```

```
import { defineEventHandler, sendRedirect, setResponseStatus } from 'h3'

export default defineEventHandler((event) => {
  if (event.path === '/discontinued-product') {
    return sendRedirect(event, '/products/similar-items', 301)
  }
  if (event.path === '/old-blog-post') {
    setResponseStatus(event, 404)
    return render404Page()
  }
})
```

## [Testing 404 Responses](#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](#common-mistakes)

### [Redirecting All 404s to Homepage](#redirecting-all-404s-to-homepage)

Creates soft 404 risk. Google may ignore

 to irrelevant pages.

```
// ❌ Bad - mass redirect to homepage
app.get('*', (req, res) => {
  res.redirect(301, '/')
})
```

Only redirect if replacement content is relevant. Otherwise return proper 404.

### [Client-Side 404 Handling Only](#client-side-404-handling-only)

JavaScript-rendered error pages return `200 OK` to search engines:

```
<!-- ❌ Bad - SPA 404 component -->
<template v-if="!pageExists">
  <h1>404 Not Found</h1>
</template>
```

Server sees `200 OK`, Google sees error content, flags soft 404. Set status server-side.

### [Forgetting noindex Meta Tag](#forgetting-noindex-meta-tag)

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

```
<script setup lang="ts">
import { useHead } from '@unhead/vue'

useHead({
  meta: [
    { name: 'robots', content: 'noindex' }
  ]
})
</script>
```

Safety net, not primary solution. Fix the status code.

### [Not Monitoring 404 Patterns](#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.

## [Using Nuxt?](#using-nuxt)

Nuxt handles 404 errors automatically with the `error.vue` component.

---

On this page

- [Quick Setup](#quick-setup)
- [Soft 404 Errors Explained](#soft-404-errors-explained)
- [Checking Routes Server-Side](#checking-routes-server-side)
- [Dynamic Routes Considerations](#dynamic-routes-considerations)
- [404 vs 410 Status Codes](#_404-vs-410-status-codes)
- [Custom 404 Page Design](#custom-404-page-design)
- [Crawl Budget Impact](#crawl-budget-impact)
- [Handling 404s for Deleted Content](#handling-404s-for-deleted-content)
- [Testing 404 Responses](#testing-404-responses)
- [Common Mistakes](#common-mistakes)
- [Using Nuxt?](#using-nuxt)

[GitHub](https://github.com/harlan-zw/nuxt-seo) [ Discord](https://discord.com/invite/275MBUBvgP)

###

-
-

Modules

-
-
-
-
-
-
-
-
-

###

-
-
-

###

Nuxt

-
-
-
-
-

Vue

-
-
-
-
-
-
-
-

###

-
-
-
-
-
-
-
-
-
-

Copyright © 2023-2026 Harlan Wilton - [MIT License](https://github.com/harlan-zw/nuxt-seo/blob/main/license) · [mdream](https://mdream.dev)