Next.js not-found pages: keep the real 404, and the loading.tsx that turns it into a soft 404
A custom not-found page in Next.js answers with a real 404 until a loading.tsx starts streaming first. Tested on Next.js 16: what returns 200 and why.
A Next.js not-found page has two jobs: show a lost visitor a way out, and tell every machine that asks that the URL does not exist — HTTP 404. A page that says "not found" while answering 200 is a soft 404, and the kit's SEO audit fails the site for it. The App Router gets this right by default. While writing this I added one three-line file to the blog route and watched every missing post switch from 404 to 200, with nothing in the build or the audit noticing. Here is how the 404 is produced, and how to keep it.
Two ways a request ends up at not-found
A URL that matches no route. The root app/not-found.tsx covers these for the whole app. The docs put it plainly: "the root app/not-found.js and app/global-not-found.js files handle any unmatched URLs for your whole application." The kit's version is a plain component with two links out, home and the demo:
export default function NotFound() {
return (
<main className="mx-auto flex min-h-[70vh] max-w-md flex-col items-center justify-center px-6 text-center">
<p className="font-mono text-sm text-zinc-500">404</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">
This page doesn't exist
</h1>
{/* links: Back home, Try the demo */}
</main>
);
}
A route that matches, with no data behind it. /blog/[slug] matches any slug at all. generateStaticParams prerenders the real posts, but dynamicParams defaults to true, so an unknown slug is rendered at request time rather than rejected. The page has to decide for itself, and it does that before rendering anything:
export function generateStaticParams() {
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
// ...render the post
}
notFound() throws. Next.js catches it and renders the nearest not-found file, which is the root one here. Against the production build, both cases answer correctly:
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/blog/no-such-post # 404
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/no-such-page # 404
Both responses also carry <meta name="robots" content="noindex">. Next.js adds that by itself to any page it serves with a 404.
Because every post is known at build time, export const dynamicParams = false would also work: the docs say segments missing from generateStaticParams "will return a 404" without running the page. The kit uses the explicit check, which keeps working if posts ever move to a database.
How a loading.tsx turns your 404 into a soft 404
The Next.js streaming guide spells out the rule that matters: "Once streaming begins, the HTTP response headers (including the status code) have already been sent to the client. You cannot change the status code or headers after streaming starts." Streaming starts "when a Suspense fallback renders (for example, a loading.tsx) or when a component suspends under a <Suspense> boundary." When notFound() fires after that point, Next.js cannot take back the 200. It injects the noindex meta tag into the stream instead.
To see what that means in practice, I added this to app/blog/[slug]/:
// app/blog/[slug]/loading.tsx — the experiment, never committed
export default function Loading() {
return <p>Loading…</p>;
}
Then I rebuilt and probed the same URLs:
/blog/no-such-postreturned 200. It still rendered the not-found UI and still carriednoindex, but the status was gone. The page does nothing slow beforenotFound(), justawait paramsand an array lookup, and that was still too late: theloading.tsxboundary wraps the page, so the fallback had already been committed.- Sending a Googlebot user agent made no difference: still 200.
/no-such-pagestill returned 404, because an unmatched URL never enters the blog segment.
That last point is why nothing caught it. The kit's audit checks for soft 404s with a URL that matches no route:
// soft-404: a garbage URL must return a real 404 status
const probe = await status(`${base}/seo-audit-404-probe-page`);
if (probe.code === 200) err("soft 404: nonexistent URLs return HTTP 200 (should be 404)");
With the loading.tsx in place that probe still gets a 404 and passes, while every mistyped or retired blog link answers 200.
The docs say the noindex keeps the streamed version out of the index, and that "some crawlers may label these responses as soft 404s". The status code is still what everything else reads. Link checkers, uptime monitors, log queries for 404s, and the audit above all see a 200 and report nothing, so a broken internal link to a renamed post shows up nowhere.
Keep the existence check outside the boundary
The fix is to decide that the thing exists before anything can stream, and only then show a loading state for the slow parts. The docs' version of this pattern puts <Suspense> inside the page instead of using a segment-level loading.tsx:
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound(); // real 404: nothing has streamed yet
return (
<Suspense fallback={<p>Loading comments…</p>}>
<SlowComments slug={slug} />
</Suspense>
);
}
For routes that can 404, that is the rule: no loading.tsx at the segment, and <Suspense> only around parts that come after the existence check. The docs list two more ways to reject a request early, because both run before rendering: proxy.ts and next.config redirects.
Things that bite
- Probe a dynamic route, not just a garbage URL. Add
curl -s -o /dev/null -w '%{http_code}' https://your-site/blog/no-such-postto your checks next to the unmatched-URL probe. The two go through different code paths. redirect()has the same problem. The streaming guide says aredirect()mid-stream "becomes a client-side redirect rather than an HTTP redirect header". The dashboard's signed-out redirect to/sign-inis a real 307 today because/dashboardhas noloading.tsx. Add one and that changes.- The 404 inherits the root layout's
<title>. The kit's 404 shows the homepage title. Withnoindexthat never reaches a search result, but it is confusing in browser history and analytics. global-not-found.jsis experimental (experimental.globalNotFound). It exists for apps with several root layouts or a dynamic root segment. With one root layout,app/not-found.tsxis enough.
The sitemap that lists only real posts — so crawlers rarely hit the 404 in the first place — is covered in one source of truth for sitemap.xml and llms.txt. Shipwright, the Next.js and Claude starter kit this blog documents, ships the not-found page, the blog route above and an npm run seo:audit script. Its soft-404 probe covers unmatched URLs, so add the dynamic-route check above next to it.