---
title: "404 Pages and SEO in Vue"
description: "404s are normal, but soft 404s get pages deindexed. Return real HTTP status codes from your Vue SSR server, not client-side error components."
canonical_url: "https://nuxtseo.com/learn-seo/vue/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
- A Vue SPA needs SSR for this: client-side routing returns `200 OK` for every path, so the server has to check the route and set the status before it responds
- 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/vue/controlling-crawlers#when-crawler-control-matters) recrawling pages it thinks exist.

You need SSR here: a pure client-side SPA returns `200 OK` for every route and only renders the "not found" view after JavaScript runs, which is exactly what search engines read as a soft 404.

## Quick Setup

Return proper 404 status codes from your server:

<code-group>

```ts [Express]
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>
  `
}
```

```ts [Vite]
// 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)
})
```

```ts [H3]
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)
})
```

</code-group>

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

### Vue SPA Soft 404 Problem

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

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

Match incoming paths against your Vue Router configuration:

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

<code-group>

```ts [Express]
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))
})
```

```ts [Vite]
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))
})
```

```ts [H3]
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)
})
```

</code-group>

## Dynamic Routes Considerations

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

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

<code-group>

```ts [Express]
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))
})
```

```ts [Vite]
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))
})
```

```ts [H3]
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)
})
```

</code-group>

## 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.

## Custom 404 Page Design

Good 404 pages keep users on your site:

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

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/vue/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/vue/controlling-crawlers/redirects) to new location:

<code-group>

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

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

```ts [H3]
import { defineEventHandler, sendRedirect } from 'h3'

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

</code-group>

### Content Permanently Removed

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

<code-group>

```ts [Express]
// ✅ 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())
})
```

```ts [Vite]
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()
})
```

```ts [H3]
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()
  }
})
```

</code-group>

## 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/vue/controlling-crawlers/redirects) to irrelevant pages.

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

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

```vue
<!-- ❌ 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

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

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

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? It handles 404 errors automatically with the `error.vue` component and `createError()`. [Learn more in Nuxt →](/learn-seo/nuxt/routes-and-rendering)

## Checklist

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

- Your server checks the route (and, for dynamic routes, whether the data exists) before responding, not after the client renders
- A missing route or missing data returns HTTP 404 from the server, not a `200 OK` with an error component
- No route redirects blanket-forward to the homepage; each 404 either stays a 404 or gets a targeted 301
- `noindex` on the error page 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>
