The Next.js not-found Page: What Actually Renders, and Why the Title Doesn't Change
notFound() throws rather than returns, so a try/catch upstream can silently swallow it. We rebuilt the site and read the generated 404 output to confirm what Next.js actually ships.
Next.js resolves a missing page two different ways, and only one of them is a real HTTP 404: throwing notFound() inside a route segment renders your not-found.tsx and sets the status to 404, while a component that just conditionally renders "not found" copy without calling it still serves 200. We rebuilt this site and read the generated output directly — the prerendered 404 page ships a "status": 404 metadata file, an auto-injected noindex meta tag, and, less usefully, a <title> tag byte-identical to the homepage's.
Two files, two different failures
Next.js 16 gives you two conventions for "this doesn't exist," and they answer different questions. not-found.tsx handles a request that resolved to a route but found no data to show there — a product slug, a blog slug, anything you look up. error.tsx handles a request that blew up while rendering — a thrown exception, a failed fetch, a bug. Conflating them is the single most common mistake: catching a "not found" case as a generic error loses the 404 status and the search-engine signal that goes with it.
not-found.tsx | error.tsx | |
|---|---|---|
| Triggered by | notFound() thrown in a Server Component, or an unmatched URL | Any uncaught exception during render |
| HTTP status | 404 | 500 (or whatever the thrown error implies) |
| Runs on | Server (can be a Server Component) | Client only — must be "use client" |
| Scope | The nearest route segment, or the whole app if none matches | The nearest error.tsx boundary |
| Recoverable? | No — the resource genuinely doesn't exist | Yes — ships a reset() you can offer the user |
This storefront ships both, and they're deliberately different shapes. src/app/not-found.tsx reuses the full Header/Footer chrome and points the visitor at /templates — a 404 here almost always means a moved or mistyped product slug, so the useful next step is the catalog, not a dead end:
// src/app/not-found.tsx
export default function NotFound() {
return (
<>
<Header />
<main id="main">
<section className="pt-40 pb-24 text-center">
<Container>
<p className="text-7xl font-bold text-primary">404</p>
<h1 className="mt-4 text-2xl font-bold text-title-color md:text-3xl dark:text-white/90">
That page doesn't exist
</h1>
<div className="mt-8 flex justify-center gap-3">
<Button href="/templates">Browse templates</Button>
<Button href="/" variant="outline">
Go home
</Button>
</div>
</Container>
</section>
</main>
<Footer />
</>
);
}
src/app/error.tsx is a client component with no navigation chrome at all — a genuine server-side crash is not the moment to render a full header with links to pages that might also be broken, so it's a bare centered message and a reset() button:
// src/app/error.tsx
"use client";
export default function Error({
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main id="main" className="flex min-h-screen items-center justify-center text-center">
<div>
<h1 className="text-2xl font-bold text-title-color dark:text-white/90">
Something went wrong
</h1>
<button type="button" onClick={reset} className="mt-6 …">
Try again
</button>
</div>
</main>
);
}
notFound() is a throw, not a return — and that has consequences
The instinct is to treat notFound() like an early return. It isn't. Reading the installed package (next@16.2.9) directly, notFound() throws an Error carrying a digest of NEXT_HTTP_ERROR_FALLBACK;404:
// node_modules/next/dist/client/components/not-found.js
const DIGEST = `${_httpaccessfallback.HTTP_ERROR_FALLBACK_ERROR_CODE};404`;
function notFound() {
const error = new Error(DIGEST);
error.digest = DIGEST;
throw error;
}
Two things follow from that, and only one of them is documented anywhere obvious. First, the reason you never need return notFound() — the function's TypeScript return type is never, so the compiler already knows nothing after the call executes. Second, and this is the one that costs people an afternoon: a try/catch upstream of the call, or wrapping the call itself, will swallow this error like any other unless it specifically re-throws anything carrying that digest. A generic "log and continue" catch block around a data-fetch is a common enough pattern that it's worth checking before you assume notFound() isn't firing — the symptom is a page that silently falls through to whatever renders next, with no 404 and no error, rather than a crash you'd notice.
This codebase calls notFound() in exactly two places, both bare, both outside any try/catch — the pattern to copy:
// src/app/templates/[slug]/page.tsx
const { slug } = await params;
const product = getProduct(slug);
if (!product) notFound();
// src/app/blog/[slug]/page.tsx
const { slug } = await params;
const post = getPost(slug);
const Content = await getPostContent(slug);
if (!post || !Content) notFound();
The blog route checks two things, not one — the metadata registry has an entry and the MDX loader resolves. That double check exists because this blog enforces a three-files-must-agree invariant between the registry, the prose file, and the loader map, covered in full in the MDX vs headless CMS post; a slug present in one but not the other is exactly the case this line exists to catch as a 404 instead of a crash.
What the built output actually contains
not-found.tsx also handles the case nobody explicitly triggers: a URL that matches no route at all. Next.js renders the nearest not-found.tsx directly, without your code ever calling the function. We rebuilt this site and read the generated files rather than trust the docs on faith. The route table marks the page static:
├ ○ /_not-found
Static (○), same symbol as the homepage — the 404 page is prerendered once at build time, not computed per request, so it costs nothing at request time and can't read anything from the failed request itself (no per-visitor personalization, no "did you mean…" from the attempted URL). The build's own metadata file confirms the status Next.js promises but doesn't show you anywhere in the terminal output:
// .next/server/app/_not-found.meta
{
"status": 404,
"headers": {
"x-nextjs-stale-time": "300",
"x-nextjs-prerender": "1"
}
}
And the prerendered HTML confirms the second promise from the framework's own JSDoc — "this will insert a <meta name="robots" content="noindex" /> meta tag" — fires even on the implicit path, where nothing in this codebase ever calls notFound() directly:
<meta name="robots" content="noindex"/>
The one thing that doesn't come free: the title
What the build does not do is give the 404 page its own <title>. not-found.tsx exports no metadata, so Next.js's metadata resolution falls back to the root layout's title.default — and the generated HTML confirms exactly that:
<title>ASoc — Premium Tailwind CSS Templates</title>
That's not a bug — title.default exists precisely to give child segments without their own title something rather than a blank tag — but it means this site's 404 page is titled identically to its homepage in every browser tab and every search result snippet that happens to surface it. A visitor with six tabs open can't tell them apart at a glance, and it's an easy thing to fix that nobody fixes, because the page still works fine without it: add a two-line metadata export to not-found.tsx (title: "Page Not Found") and the root layout's template: "%s — ASoc" handles the rest.
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
Rendering "not found" UI without calling notFound() | Page returns HTTP 200 with 404-looking content — search engines index it as a real page | Call notFound(); never fake the UI conditionally |
A try/catch around code that calls notFound() | The thrown digest gets swallowed; rendering falls through instead of 404ing | Re-throw anything whose digest starts with the fallback error code, or don't wrap the call at all |
No local metadata on not-found.tsx | 404 page's <title> is identical to the homepage's | Add a two-line metadata export with a distinct title |
Assuming notFound() needs a return | Harmless, but confuses readers of the diff | never return type already makes code after it unreachable — the pattern above omits return on purpose |
Expecting not-found.tsx to see the failed URL | Nothing in the file has access to the attempted path | It's prerendered once at build time — there's no per-request data to read |
Frequently asked questions
Does not-found.tsx really send a 404 status code, or just look like one?
A real one, for a non-streamed response — confirmed here directly from .next/server/app/_not-found.meta's "status": 404, not just from the docs. Streamed responses are a documented exception (a 200 status ships first, before the 404 is known), which matters for anything served through a proxy or edge function that inspects status codes before the body finishes.
Do I need to add noindex myself?
No. notFound()'s own JSDoc states it injects <meta name="robots" content="noindex" />, and this build's generated HTML confirms it fires even for a URL that never explicitly calls the function — Next.js's own router reaches not-found.tsx directly for anything unmatched.
What's global-not-found.js for, and do I need it?
It's an experimental, opt-in file (experimental.globalNotFound in next.config.ts) for apps with more than one root layout, or a top-level dynamic segment in the root layout — cases where there's no single layout to compose a normal not-found.tsx from. This site has one root layout and no dynamic segment above it, so the plain convention is the right one; adding the experimental flag here would be solving a problem this app doesn't have.
Can a Route Handler or Server Action call notFound() too?
Yes — the same function works there, and just serves a 404 to the caller instead of rendering the not-found.tsx UI, since there's no page to render into.
Templates with the routing groundwork already in place
ASoc Apex Admin is a large multi-purpose admin — 5 dashboards and 115+ pages, the scale at which a missing-record 404 (a deleted order, an unassigned ticket) is a routine case rather than an edge case. ASoc Clover Admin is CRM-focused, with customer, deal and activity detail pages that are exactly the "record might not exist" shape this post's templates/[slug] example covers. ASoc Vertex Admin ships a full sidebar navigation shell around its eCommerce dashboard, so wiring the rest of that shell to real routes is where getting not-found.tsx right for not-yet-built pages matters.
Browse the full set of React admin templates, Next.js admin templates, or Tailwind admin templates.
