
Next.js notFound
Every app built on dynamic data eventually hits the same question: a user requests /blog/a-post-that-was-deleted or /user/999999, and your data layer comes back empty. What do you render? Returning a normal page with "not found" text baked into the body is a trap — the response still carries a 200 status code, so search engines index it as a real page, monitoring tools don't flag it as an error, and anyone scripting against your site can't distinguish "this resource doesn't exist" from "this resource loaded successfully and happens to say the word 'not found' somewhere."
Next.js gives you a purpose-built way out of this: the notFound() function from next/navigation. Calling it doesn't return a value or set a flag — it throws, and Next.js catches that specific throw to swap in your 404 UI and correct the response's metadata. It's a small function with exactly one job, but the mechanics around how it interrupts rendering, where you're allowed to call it from, and how it behaves once streaming has already started are worth understanding properly, because getting them wrong produces some genuinely confusing failure modes.
This article is specifically about the notFound() function — the thing you call in your code. If you're looking for the not-found.tsx file convention that defines what renders when this function is triggered, that's a separate piece of the puzzle covered elsewhere; here we're focused entirely on the calling side.
What notFound() Actually Does
Under the hood, notFound() throws an error with the message NEXT_HTTP_ERROR_FALLBACK;404. Next.js's rendering pipeline recognizes this exact error internally and treats it as a signal rather than a crash: it stops rendering the route segment where the throw occurred, and instead renders the nearest not-found boundary up the component tree. At the same time, it injects a <meta name="robots" content="noindex" /> tag into the response, so even if the HTTP status code situation is more nuanced than a clean 404 (more on that below), search engines are told explicitly not to index the page.
Because it works by throwing, notFound() has to be called somewhere in the actual render path — inside a component's function body, or inside a function that a component awaits before rendering. It is not a "check this and continue" utility; it's a hard stop. This is the single most important thing to internalize about it, because it explains almost every gotcha in the rest of this article.
Where You Can Call It From
The docs are specific about this: notFound() works in Server Components, in Server Functions (the things you'd write for a form submission or mutation), and in Route Handlers. It does not work in Client Components — there's no client-side equivalent, because the whole mechanism depends on Next.js's server-side rendering pipeline catching the throw before anything reaches the browser. If you try to call it from client-side code, you'll just get an unhandled exception with no special handling.
The most common shape you'll write is a data-fetching function inside a Server Component page:
import { notFound } from "next/navigation";
async function fetchUser(id: string) {
const res = await fetch("https://api.example.com/users/" + id);
if (!res.ok) return undefined;
return res.json();
}
export default async function Profile({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const user = await fetchUser(id);
if (!user) {
notFound();
}
return <div>{user.name}</div>;
}
Note that params is a Promise you have to await — that's current App Router behavior, not something specific to this function, but it trips up anyone copying older examples from memory.
You Don't Need to Write return notFound()
A detail that saves you a few keystrokes but matters more for what it tells you about the function: you never need to write return notFound(). Because the call throws and immediately halts execution of the current function, nothing after it runs anyway. Writing return notFound() isn't wrong, exactly, but it implies the function has a return value worth capturing, and it doesn't — its return type is never.
That never type is actually useful for TypeScript's control-flow narrowing. If you check a value, call notFound() in the falsy branch, and then use the value afterward, TypeScript understands the value can no longer be undefined at that point:
const user = await fetchUser(id);
// user: User | undefined
if (!user) {
notFound();
}
// TypeScript now knows `user` is definitely `User` here,
// because the only other branch never returns
return <Profile user={user} />;
This is a genuinely nice pattern once you notice it — you get the safety of an early-return guard clause without needing a type assertion or non-null assertion operator anywhere.
The try/catch Trap
Because notFound() works by throwing a specific internal error, anything that catches errors indiscriminately will intercept it before Next.js gets a chance to handle it. If you wrap a call to notFound() (or a function that calls it) inside a try/catch block, the not-found UI simply won't render — your catch block swallows the throw, and whatever you do in there runs instead.
// This will NOT show the not-found UI
try {
const user = await fetchUser(id);
if (!user) {
notFound();
}
} catch (error) {
// notFound()'s throw lands here, indistinguishable from a real error
console.error(error);
}
This is a real footgun if you have generic error-handling wrappers around your data-fetching logic — a pattern that's genuinely common in larger codebases where every data call goes through a shared try/catch for logging. If you need error handling near a notFound() call and want the interrupt to still propagate correctly, Next.js provides unstable_rethrow specifically for this: you catch the error, check whether it's one of Next.js's internal control-flow errors (which includes notFound(), redirect(), and a few others), rethrow it if so, and only handle it yourself otherwise.
import { unstable_rethrow } from "next/navigation";
try {
const user = await fetchUser(id);
if (!user) {
notFound();
}
} catch (error) {
unstable_rethrow(error);
// only errors that aren't Next.js control-flow signals reach here
console.error(error);
}
notFound() vs. Its Siblings
Next.js ships a small family of functions that all work the same way — throw a recognized internal error, let the framework intercept it — but each maps to a different HTTP-level meaning. It's worth being precise about which one you reach for:
notFound()— the resource genuinely doesn't exist. Maps conceptually to a 404.forbidden()— the resource exists, but the current user isn't allowed to see it under any circumstances (403). Requires theauthInterruptsexperimental flag.unauthorized()— the user isn't authenticated at all, and needs to sign in first (401). Also requiresauthInterrupts.redirect()— the resource has moved, temporarily or permanently, and the user should be sent somewhere else entirely.
Mixing these up is a common mistake. Reaching for notFound() when a resource actually exists but the user lacks permission is misleading both to users (who might reasonably re-request the URL, or share it with someone who does have access) and to anyone monitoring your error rates, since a 404 and a 403 usually warrant very different responses from your team.
Calling notFound() After Streaming Has Started
This is where the behavior gets genuinely subtle, and it's the part of the docs most worth reading carefully.
If you want a page's shell and loading UI to appear immediately while the "does this actually exist" check happens in the background, the idiomatic pattern is to wrap the data-dependent part of the page in <Suspense>, and do the existence check inside the async component that Suspense is waiting on:
import { Suspense } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`);
if (res.status === 404) {
notFound();
}
if (!res.ok) {
throw new Error(`Failed to load post: ${res.status}`);
}
return res.json();
}
async function Article({ slug }: { slug: string }) {
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return (
<section>
<Link href="/blog">Blog</Link>
<Suspense fallback={<p>Loading...</p>}>
<Article slug={slug} />
</Suspense>
</section>
);
}
When getPost throws via notFound(), the error propagates up to the nearest not-found boundary and that UI replaces the Article component in place — the surrounding shell (the "Blog" link, in this example) stays exactly where it was.
Here's the catch: because this all happens inside a <Suspense> boundary, the HTTP response has already started streaming to the browser as a 200 before the not-found check even runs. Once a response has started streaming, its status code can't be changed retroactively — HTTP doesn't allow it. So the page technically returns 200, dressed up with the noindex meta tag to keep it out of search results. This is sometimes called a "soft 404," and for most user-facing purposes it behaves identically to a real one — but if you have tooling, monitoring, or an API contract that specifically checks status codes, a soft 404 won't trip it.
If you need a genuine 404 status code, the existence check has to happen before the response starts streaming — which means outside the Suspense boundary, in code that runs before any bytes go out. If you're using Cache Components, where every dynamic route streams a static shell first by design, the docs specifically recommend doing this check in proxy (the file that replaced Next.js Middleware) instead, since that runs before any rendering begins at all.
Serving a 404 from a Route Handler
notFound() isn't limited to pages — it works inside Route Handlers too, where it produces a genuine 404 response to whatever's calling the API:
import { NextResponse } from "next/server";
import { notFound } from "next/navigation";
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const res = await fetch(`https://api.example.com/posts/${slug}`);
if (!res.ok) {
notFound();
}
return NextResponse.json(await res.json());
}
This is a genuinely convenient pattern for API routes — instead of manually constructing a NextResponse.json({ error: "not found" }, { status: 404 }), you get consistent 404 behavior for free, matching whatever your app-wide not-found handling looks like.
Common Mistakes
Leaving a notFound() call inside an un-awaited promise. If you call an async function that eventually calls notFound(), but you don't await that function's promise before your component finishes rendering, the throw happens somewhere nothing is listening. In development, you'll see ⨯ unhandledRejection: NEXT_HTTP_ERROR_FALLBACK;404 logged to the server console, and no not-found UI will render at all. The fix is straightforward once you know to look for it: always await any function that might call notFound().
Wrapping the call in a broad try/catch without unstable_rethrow. Covered above, but worth repeating because it's easy to introduce accidentally when refactoring — someone adds error logging around a data-fetching call months after the notFound() logic was written, and suddenly 404s silently disappear into a log line instead of rendering.
Forgetting to add a not-found.tsx for the specific route. Without one, Next.js falls back to the nearest parent not-found boundary, and ultimately to its own generic default 404 page if none exists anywhere in the tree. That's not wrong, exactly, but it means a blog post's 404 might look like your entire site's fallback 404 rather than something contextual ("this post doesn't exist, try browsing the blog instead").
Confusing a soft 404 for a hard requirement violation. If your monitoring dashboards alert on non-200 status codes, a notFound() call inside a Suspense boundary won't show up — the response is a 200 with a noindex tag, not an actual 404. If accurate status-code reporting matters for a given route, move the check earlier in the request lifecycle.
Key Takeaways
| Scenario | What to reach for |
|---|---|
| Resource genuinely doesn't exist | notFound() |
| Resource exists, but current user can't access it under any login | forbidden() |
| User isn't signed in at all | unauthorized() |
| Resource moved | redirect() |
Need a real 404 status code, not a soft one | Check for existence before streaming starts (outside Suspense, or in proxy under Cache Components) |
Need error logging near a notFound() call | Catch the error, call unstable_rethrow(error), then handle only what's left |
| Calling from a Client Component | Not supported — do the check server-side instead |
notFound() is a small function, but it encodes a genuinely useful idea: signaling "this doesn't exist" should be a first-class, unambiguous action in your code, not an incidental side effect of an empty array or a falsy check buried in JSX. Once you understand that it works by throwing — and everything that implies about try/catch, un-awaited promises, and where in the render lifecycle you call it — it becomes one of the more reliable building blocks in the App Router's toolkit.


