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 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.
Return proper 404 status codes from your server:
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 detection happens when Google sees content that looks like an error page but receives 200 OK status (Google Search Central).
Common triggers:
Google Search Console flags soft 404s in the "Page Indexing" report. Fix by returning proper 404 status code.
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.
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:
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 (/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:
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 Not Found - Resource doesn't exist, might never have existed, might come back:
410 Gone - Resource existed, now permanently removed:
Google treats both similarly for indexing—removes from search results. 410 signals faster removal but rarely needed. Use 404 for most cases.
Good 404 pages keep users on your site:
<script setup>
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:
Do:
404 errors have minimal crawl budget impact (Google Search Central). Google expects them. Soft 404s waste crawl budget because Google recrawls pages thinking content exists.
Large sites (10,000+ pages) should:
Don't worry about occasional 404s from external links or user typos.
Use 301 redirect to new location:
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)
}
})
Return 404 or 410. If similar content exists, redirect to relevant category:
// ✅ 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()
}
})
Verify proper status codes before deploying:
Browser DevTools:
404 not 200Command line:
curl -I https://example.com/fake-page
# Output should show:
# HTTP/1.1 404 Not Found
Google Search Console:
Lighthouse: Run Lighthouse audit, check "Crawling and Indexing" section for status code issues.
Creates soft 404 risk. Google may ignore redirects 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.
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.
If 404 page accidentally returns 200 OK, noindex prevents indexing:
<script setup>
import { useHead } from '@unhead/vue'
useHead({
meta: [
{ name: 'robots', content: 'noindex' }
]
})
</script>
Safety net, not primary solution. Fix the status code.
Repeated 404s to same path indicate broken internal links or outdated external links. Check Search Console "Not Found" report monthly, fix internal links immediately.
Nuxt handles 404 errors automatically with the error.vue component and proper status codes. Check out Nuxt SEO for built-in crawl budget optimization and error handling.
Hreflang & i18n
Set hreflang tags in Vue to tell search engines which language version to show users. Avoid duplicate content penalties across multilingual sites.
Dynamic Routes
How to configure Vue Router dynamic route params, set per-route meta tags, and avoid duplicate content issues with URL parameters.