The Soft 404 Page Issue with Next.js SSR and How to Fix It
A guide to resolving the Soft 404 page issue in Next.js SSR applications. (Temporal solution)
On this page
Introduction
I ran into a Soft 404 problem while using the Next.js App Router with server-side rendering. Some invalid pages showed a visible 404 screen, but the HTTP response still returned 200 OK.
That is a problem for SEO. A human can understand the page is missing, but crawlers depend heavily on the status code. If a missing page returns 200, search engines may treat it as a low-quality valid page instead of a real 404.
This post explains what I saw, why it happens, and the temporary workaround I used.
The Problem
For a missing blog post, Chrome DevTools showed this:
General
Request URL: https://sivothajan.me/blogs/i/999999999
Request Method: GET
Status Code: 200 OK
Remote Address: 216.198.79.1:443
Referrer Policy: strict-origin-when-cross-origin
The page looked like a 404 page, but the server response was 200 OK.
That is the classic Soft 404 shape:
- The content says the page does not exist.
- The HTTP status says the request succeeded.
- Search engines receive mixed signals.
Why It Happens
Next.js App Router can stream server-rendered HTML. Once streaming begins, response headers and status codes are already committed. If the application discovers a missing resource after that point, the page can render a 404 UI without being able to change the HTTP status code.
This is not a simple styling bug. It comes from the timing of streamed rendering.
In a GitHub discussion, a Next.js maintainer summarized the core issue: once the preamble of a streamed response has started, status and headers cannot be changed during Server Component rendering.
That means notFound() can still produce a user-facing 404, but depending on where and when the missing state is discovered, the network response may not always be the status code you expect.
The Workaround
The workaround I used was pre-SSR route validation.
Instead of letting an invalid request enter the streaming render path, I check the route earlier in proxy.ts. If the route is not valid, the request is rewritten to the 404 page with an explicit 404 status.
The idea is:
- Match only the dynamic routes that need validation.
- Normalize the incoming pathname.
- Check it against a known list of valid paths.
- Return a real 404 before SSR starts if the path is invalid.
Example Matcher
export const config = {
matcher: [
'/blogs/i/:id*',
'/blogs/n/:name*'
// Add more SSR routes as needed.
]
};
Keep the matcher focused. Do not run validation middleware for every route if only a few dynamic SSR pages need it.
Example Proxy Logic
import { NextRequest, NextResponse } from 'next/server';
/**
* Validate dynamic SSR routes before the request reaches
* the streaming render path.
*/
export default async function proxy(req: NextRequest) {
const pathname = req.nextUrl.pathname.replace(/\/$/, '').toLowerCase();
let validPaths: string[] = [];
try {
validPaths = await fetchValidPathsFromExternalAPI();
} catch (error) {
console.error('Failed to fetch valid paths:', error);
return NextResponse.rewrite(new URL('/404', req.url), { status: 404 });
}
if (!validPaths.includes(pathname)) {
return NextResponse.rewrite(new URL('/404', req.url), { status: 404 });
}
return NextResponse.next();
}
With this approach, invalid paths are rejected before the page renderer starts streaming. That gives the server a chance to return the correct status code.
Important Performance Note
Do not fetch valid paths from a slow source on every request.
If route validation depends on a database, API, CMS, or generated file, make sure that data is cached properly. A CDN-hosted JSON file, KV store, edge cache, or build-generated manifest can work well depending on the project.
For my use case, serving a valid-paths list from static hosting was enough.
Caveat
This is a workaround, not a perfect universal fix.
In some cases, NextResponse.rewrite() can still behave differently than expected if the status is not explicitly set. That is why the example uses:
return NextResponse.rewrite(new URL('/404', req.url), { status: 404 });
The explicit status matters.
Conclusion
The cleanest solution would be a framework-level way to preserve correct HTTP status codes for streamed SSR 404 states.
Until then, pre-validating known dynamic routes is a practical workaround. It avoids sending invalid requests into the streaming renderer and helps crawlers receive the correct signal.
This fixed the Soft 404 issue for my SSR routes while keeping the rest of the application behavior unchanged.
What I Learned
- A 404-looking page is not enough; the HTTP status code matters.
- Streaming SSR can commit headers before the missing state is known.
- Dynamic routes benefit from early validation when SEO matters.
- Route manifests should be cached and cheap to read.
- Workarounds should be narrow, measurable, and easy to remove later.
References
- Next.js documentation on streaming status codes
- Next.js proxy documentation
- GitHub Discussion: Support custom HTTP status code for Server Components
- GitHub Issue: 404 page is not server rendered when using notFound()
- GitHub Issue: Incorrect HTTP error status codes from non-streaming generateMetadata
- GitHub Issue: Incorrect response status code when using NextResponse.rewrite(url, { status }) in middleware
- Google: Rendering on the Web