Our 404 page answers 200, and we decided to keep it that way
I run a site with practice exams for Dutch certification exams. Each exam has a few fixed practice forms, at URLs like /examen/b-vca/1/, /examen/b-vca/2/ and so on. Last month I typed /examen/b-vca/999/ to check the erro
I run a site with practice exams for Dutch certification exams. Each exam has a few fixed practice forms, at URLs like /examen/b-vca/1/, /examen/b-vca/2/ and so on. Last month I typed /examen/b-vca/999/ to check the error page.
The page looked right: "Deze pagina bestaat niet" ("this page doesn't exist"), the normal layout, a link back. Then I looked at the status code. 200.
A 404 page served with a 200 is what Google calls a soft 404. Here's why it happens in Next.js 16 with partial prerendering, what I tried, and why I ended up leaving it.
Why the status is already gone
The exam page is built the way Next.js 16 wants you to build it with cacheComponents: true. The page itself is a static shell, and everything that needs the request goes inside a Suspense boundary:
export const metadata: Metadata = {
title: "Oefenexamen",
robots: { index: false, follow: false },
};
export default function ExamPage({ params, searchParams }: PageProps<"/examen/[exam]/[form]">) {
return (
<Suspense fallback={<Notice title="Oefenexamen ladenโฆ" />}>
<ExamForm params={params} searchParams={searchParams} />
</Suspense>
);
}
Inside, the component looks up the exam and calls notFound() when the form number doesn't exist:
if (!exam || !/^[1-9][0-9]*$/.test(form) || n > exam.forms.fixedCount) notFound();
With partial prerendering, the server sends the prerendered shell first and streams the dynamic part after it. By the time notFound() runs inside the Suspense boundary, the response headers, including 200 OK, have already gone out. Next.js still renders the not-found UI into the stream, so a person sees the right page. A crawler that only reads the status sees a normal page.
URLs that don't match any route are fine. They go to global-not-found.tsx and get a real 404. Only an unknown value inside a dynamic route that streams is affected.
What I tried
generateStaticParams. List the real forms, and anything else should 404. It doesn't work that way here. The listed params get prerendered, but an unlisted one still gets the shell and renders on demand, so we're back at the same stream.
dynamicParams = false. This is the classic switch for "only the params I listed". With cacheComponents enabled, Next.js doesn't allow it on the route.
Making the page fully static. Then notFound() would run at build time and the status would be right. But this page reads the visitor's session cookie and the database (who is this, and do they have access). It can't be static.
A proxy in front of the route. This is the one that actually works. A proxy.ts (what used to be middleware.ts) checks the exam and form number before the page runs and returns a 404 for unknown ones. The cost is that it runs on every request under /examen/, including the server actions that save answers and hand in the exam. It also needs a list of valid exam and form pairs at the edge, generated from the content and kept in sync. That's a new moving part on the hottest path in the app, to fix a URL nobody links to.
Why we kept the soft 404
What matters about a soft 404 is that a search engine might index an error page or waste crawl budget on it. So I checked what a search engine can actually do with these URLs:
-
Every exam page is
noindex, nofollow, including the real ones. A practice exam isn't a page anyone should land on from search. The guides that link to it are. -
/examen/is disallowed inrobots.txt. Well-behaved crawlers don't fetch the page at all. - Nothing links to a form that doesn't exist. The links to exam forms are generated from the same data that decides which forms exist.
So no crawler should ever see the 200, and if one does, the page tells it not to index anything. The fix would add risk to the part of the app where a bug costs a user their exam attempt. The problem it fixes has no visible effect.
We wrote it down as a decision with a condition: revisit it if a crawler or an uptime monitor ever needs the real status. Then the proxy plus a generated exam/form map is the way to do it.
What I'd tell someone else on Next.js 16
-
Check your error status codes with curl, not the browser.
curl -s -o /dev/null -w "%{http_code}" <url>takes a second. The page looking right tells you nothing about the header. -
notFound()inside Suspense can't change the status once the shell has streamed. If the status matters, the check has to happen before streaming starts: at build time, or in a proxy. -
Decide per route whether the status matters. On public, indexable routes it does, so make those static or check them early. On routes that are
noindexand disallowed anyway, a soft 404 can be the cheaper, safer trade. - Write the decision down with the condition that would change it. Otherwise someone (probably you) rediscovers the 200 in three months and spends an evening on it again.
The site is examenzaal.nl, practice exams for Dutch certificates. The real exam forms start from pages like the B-VCA practice page.
I wrote this post with help from an AI assistant. The code and the decision come from our own repository.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.