Next.js not-found.tsx: Empty 404 HTML and the Fix
Open a missing URL on a Next.js site, like /blog/no-such-post, and you see your 404 page. Fetch the same URL with curl and the status is 404, but the HTML has no 404 page in it. The body is empty, the <html> tag has lost its lang attribute, and the title is your home page title. The text you wrote in not-found.tsx only appears after JavaScript runs.
I found this on this site, then tested it on Next.js 16.4.4 with next build and next start: a root layout with a title template, app/not-found.tsx with its own metadata, and three [slug] routes that call notFound() for any slug they don't know.
What the HTML looked like
/nope 404 <html lang="en"> title "Page not found | Site" <h1>ROOT not found
/blog/missing 404 <html id="__next_error__"> title "Site" (no h1)
/shop/missing 404 <html id="__next_error__"> title "Site" (no h1)
/docs/missing 404 <html id="__next_error__"> title "Site" (no h1)/nope matches no route, so Next.js rendered app/not-found.tsx on the server and the page was complete. The other three matched a [slug] route and called notFound() from inside the page. With the scripts removed, this is all the HTML they sent:
<!DOCTYPE html><html id="__next_error__"><body><div hidden=""><!--$--><!--/$--></div></body></html>The not-found text was there, but only inside the React Server Components payload in a script tag, which the browser turns into the page after JavaScript loads. It happened for a route with generateStaticParams (/blog and /docs) and for a fully dynamic one (/shop). It didn't matter whether the segment had its own not-found.tsx.
The 404 status and the noindex tag were right, so Google won't index these pages either way. What you lose is the page itself for anything that reads the HTML without running JavaScript: it gets an empty page with no lang attribute and your home page title instead of "Page not found".
The fix: dynamicParams = false
If a route has generateStaticParams and you know every valid slug at build time, tell Next.js to treat any other slug as an unmatched URL:
// app/blog/[slug]/page.tsx
export const dynamicParams = false;
export function generateStaticParams() {
return posts.map((post) => ({ slug: post.slug }));
}/docs/missing 404 <html lang="en"> title "Page not found | Site" <h1>ROOT not foundNow the request never reaches the page component. Next.js answers it like /nope, with app/not-found.tsx rendered on the server, its metadata in the head and noindex still set.
There is a trade-off. With dynamicParams = false, /blog/missing used app/not-found.tsx, not app/blog/not-found.tsx. A segment-level not-found page only runs when notFound() is called, and with this setting it never is for unknown slugs. If you need a different 404 page per section, keep that in mind.
It also only works when every slug is known at build. For a route whose slugs come from a CMS or a database at request time, like /shop here, keep dynamicParams on and accept that the 404 body is filled in by JavaScript.
Give the 404 page its own metadata
Two more things showed up in the head of the unmatched URL.
The canonical. With canonical: "./" in the root layout, /nope came out with a canonical of https://example.com/_not-found, an internal route name. With a fixed canonical of "/" in the layout, every missing URL points at your home page instead. A 404 doesn't need a canonical, so remove it in not-found.tsx:
// app/not-found.tsx
export const metadata: Metadata = {
title: "Page not found",
alternates: { canonical: null },
};After that the canonical was gone and the title was "Page not found | Site", from the layout's template. Next.js added <meta name="robots" content="noindex"> on every 404 page without being asked.
Check your own site
curl -s -w "\n%{http_code}\n" https://your-site.com/blog/no-such-post \
| grep -oE '<html[^>]*>|<title>[^<]*|<h1[^>]*>[^<]*|^[0-9]{3}$'You want 404, <html lang="en">, your not-found title and your heading. If you see id="__next_error__" and no heading, the 404 page is only being drawn by JavaScript.
Related
- Next.js notFound() returning 200 instead of 404? Tested fix
- Next.js generateMetadata: get the current URL, tested
- Next.js canonical URL: 4 traps I tested and the fix
- How to noindex a page in Next.js, and the traps I tested
- Next.js metadata checker