
Building Custom 404 Pages with not-found.tsx in Next.js
A 404 page is one of the most visited pages on a typical site, and one of the least designed. People land on it from old links, typos, deleted products, and shared URLs that no longer resolve. A good 404 page keeps them on your site by pointing them somewhere useful. A bad one is a dead end.
In the Next.js App Router, 404s are handled by a file convention, not-found.tsx, and a function, notFound(). Together they cover two different situations: a URL that doesn't match any route at all, and a URL that matches a route but points at something that doesn't exist, like /blog/a-post-that-was-deleted.
This post walks through both cases, how to scope 404 pages to sections of your app, how to make them helpful with real data, and the HTTP status code details that matter for search engines.
Two Kinds of "Not Found"
It's worth separating the cases up front, because Next.js handles them differently.
- Unmatched URLs. Someone visits
/pricngand there's no route for it. Next.js renders the rootapp/not-found.tsxautomatically. - Missing resources. Someone visits
/blog/old-post. The routeapp/blog/[slug]/page.tsxmatches, but your database has no post with that slug. Next.js can't know that on its own. You have to callnotFound(), which renders the nearestnot-found.tsx.
The first case needs no code beyond the file. The second case is where most of the real work is.
The Root not-found.tsx
Create app/not-found.tsx and Next.js uses it for every unmatched URL in your app:
// app/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<main className="mx-auto max-w-xl px-6 py-24 text-center">
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500">
404
</p>
<h1 className="mt-2 text-3xl font-bold">Page not found</h1>
<p className="mt-4 text-gray-600">
The page you're looking for doesn't exist or has moved.
</p>
<div className="mt-8 flex justify-center gap-4">
<Link href="/" className="rounded bg-black px-4 py-2 text-white">
Go home
</Link>
<Link href="/blog" className="rounded border px-4 py-2">
Browse the blog
</Link>
</div>
</main>
);
}
A few properties of this file that differ from error.tsx:
- It's a Server Component by default. No
"use client"needed, and you can make itasyncto fetch data. - It takes no props. Next.js doesn't pass the path or any error information.
- It renders inside your root layout. Your header, footer, fonts, and global styles are all there, so the 404 page looks like the rest of the site.
Next.js also adds <meta name="robots" content="noindex" /> to 404 responses automatically, so search engines won't index the page even if the status code ends up being 200 (more on that later).
Triggering a 404 with notFound()
For dynamic routes, you decide when something doesn't exist. Import notFound from next/navigation and call it when your lookup comes back empty:
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getPostBySlug } from "@/lib/posts";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) {
notFound();
}
return (
<article className="prose mx-auto">
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
params is a promise in Next.js 16, so you await it before reading the slug. Then:
notFound()throws a special error. Execution stops right there, so you don't needreturn notFound().- Its return type is
never, so TypeScript narrowspostto a non-null value after the check. Nopost!or optional chaining needed below it. - Rendering of the segment stops, and Next.js renders the nearest
not-found.tsxinstead.
You can call notFound() in Server Components, Server Functions, and Route Handlers. In a Route Handler it simply sends a 404 response to the caller.
Put the Check in Your Data Layer
If several pages read the same resource, it's tidy to call notFound() inside the data function itself:
// lib/posts.ts
import { notFound } from "next/navigation";
export type Post = { slug: string; title: string; html: string };
export async function getPostOrNotFound(slug: string): Promise<Post> {
const res = await fetch(`https://cms.example.com/posts/${slug}`);
if (res.status === 404) notFound();
if (!res.ok) throw new Error(`CMS request failed: ${res.status}`);
return res.json();
}
Notice the two branches. A 404 from the CMS means the content genuinely doesn't exist, so it becomes a 404 page. Any other failure is a real error and should throw, which sends it to your error.tsx boundary instead. Collapsing both into notFound() would hide outages behind a "page not found" message. If you want to understand that error path, see custom error boundaries with error.tsx.
Watch Out for try/catch
Because notFound() works by throwing, a surrounding try/catch will catch it and the 404 never renders:
// Don't do this
try {
const post = await getPostOrNotFound(slug);
return <Article post={post} />;
} catch (err) {
console.error(err); // swallows notFound()
return <p>Something went wrong.</p>;
}
Either keep notFound() outside the try block, or call unstable_rethrow(err) from next/navigation as the first line of the catch. It re-throws Next.js's internal control-flow errors and ignores everything else.
Call It in the Render Path
notFound() has to run somewhere React is waiting on: the component body, or a function the component awaits. If you fire off a promise without awaiting it and call notFound() inside, nothing catches the throw and no 404 UI appears. In development you'll see an unhandledRejection log mentioning NEXT_HTTP_ERROR_FALLBACK;404, which is the clue.
Scoped 404 Pages
notFound() renders the nearest not-found.tsx walking up from the segment where it was called. That lets you give different sections their own 404 UI:
app/
├── layout.tsx
├── not-found.tsx # unmatched URLs and fallback for everything
├── blog/
│ ├── layout.tsx
│ ├── not-found.tsx # missing posts
│ └── [slug]/
│ └── page.tsx # calls notFound()
└── shop/
├── not-found.tsx # missing products
└── [productId]/
└── page.tsx
When app/blog/[slug]/page.tsx calls notFound(), Next.js renders app/blog/not-found.tsx inside the blog layout. The blog sidebar, category navigation, and anything else in app/blog/layout.tsx stay on screen.
A blog-specific 404 can be much more helpful than a generic one:
// app/blog/not-found.tsx
import Link from "next/link";
import { getRecentPosts } from "@/lib/posts";
export default async function BlogNotFound() {
const recent = await getRecentPosts(5);
return (
<section className="py-16">
<h1 className="text-2xl font-bold">We couldn't find that post</h1>
<p className="mt-2 text-gray-600">
It may have been renamed or removed. Here are the latest posts instead:
</p>
<ul className="mt-6 space-y-2">
{recent.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`} className="underline">
{post.title}
</Link>
</li>
))}
</ul>
<Link href="/blog" className="mt-8 inline-block font-medium">
All posts →
</Link>
</section>
);
}
Because not-found.tsx is a Server Component, the async function and data fetch work exactly like in a page. Just make sure that fetch can't fail in a way that breaks the 404 itself. If getRecentPosts might throw, catch it there and fall back to an empty list.
Where not-found.tsx Sits in the Hierarchy
Within a segment, not-found.tsx renders inside the Suspense boundary from loading.tsx and inside the error boundary from error.tsx. That means:
- A slow data fetch in your not-found component shows the segment's loading UI.
- If your not-found component throws, the segment's
error.tsxcatches it.
An important limit: the root app/not-found.tsx is the only one that handles unmatched URLs. A not-found.tsx in app/blog won't automatically catch /blog/some/deep/unknown/path unless a route matches and calls notFound(). If you want that behavior, add a catch-all route:
// app/blog/[...rest]/page.tsx
import { notFound } from "next/navigation";
export default function BlogCatchAll() {
notFound();
}
Now any URL under /blog that doesn't match a more specific route ends up at app/blog/not-found.tsx. Dynamic and static routes take priority over catch-all segments, so your real pages are unaffected.
Using Client Hooks on a 404 Page
not-found.tsx doesn't receive the requested path as a prop. If you want to show it ("We couldn't find /pricng") or suggest a correction, use a small Client Component with usePathname:
// app/ui/missing-path.tsx
"use client";
import { usePathname } from "next/navigation";
export function MissingPath() {
const pathname = usePathname();
return (
<p className="mt-2 font-mono text-sm text-gray-500">
Requested: {pathname}
</p>
);
}
// app/not-found.tsx
import Link from "next/link";
import { MissingPath } from "@/app/ui/missing-path";
export default function NotFound() {
return (
<main className="mx-auto max-w-xl px-6 py-24 text-center">
<h1 className="text-3xl font-bold">Page not found</h1>
<MissingPath />
<Link href="/" className="mt-8 inline-block underline">
Back to the homepage
</Link>
</main>
);
}
The page stays a Server Component and only the small path display ships JavaScript. A natural next step is a search box pre-filled with words from the path, which is often the most useful thing a 404 page can offer.
Status Codes and SEO
This is the part people get wrong. Whether a 404 page actually returns HTTP 404 depends on whether the response had started streaming when notFound() was called.
- Non-streamed responses return
404. IfnotFound()runs before anything is sent, Next.js can set the status. - Streamed responses return
200. Once aloading.tsxfallback or aSuspenseboundary has flushed HTML to the browser, the headers are already sent and the status can't change.
In the streamed case, Next.js still includes the noindex meta tag in the HTML, so search engines won't index the URL. Some tools may report these as "soft 404s", but they don't end up in the index.
If you need a real 404 status (for analytics, monitoring, or compliance), make sure the check happens before streaming starts:
- Call
notFound()before anySuspenseboundary orloading.tsxfallback in that segment. - Avoid a
loading.tsxin the segment that performs the check, or move the check into the layout above it. - For apps using Cache Components, where every dynamic route streams a static shell first, move the existence check into
proxy.tsand return a404response there.
Here's what that proxy approach can look like for a blog backed by a fast slug lookup:
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { postExists } from "@/lib/post-index";
export const config = {
matcher: "/blog/:slug",
};
export async function proxy(request: NextRequest) {
const slug = request.nextUrl.pathname.split("/").pop() ?? "";
if (!(await postExists(slug))) {
return new NextResponse("Not Found", { status: 404 });
}
return NextResponse.next();
}
Keep this kind of check cheap. postExists should hit a small index or cache, not load the full post. The proxy runs on every matching request, so a slow lookup slows down every page view. For background on what the proxy layer can do, see understanding middleware in Next.js (renamed to Proxy in Next.js 16).
For a mostly static site, the simplest option is to prerender known slugs with generateStaticParams and set dynamicParams = false. Unknown slugs then never reach your page component and get the root 404 with a proper status:
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { getAllSlugs, getPostBySlug } from "@/lib/posts";
export const dynamicParams = false;
export async function generateStaticParams() {
const slugs = await getAllSlugs();
return slugs.map((slug) => ({ slug }));
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return <h1>{post.title}</h1>;
}
global-not-found.tsx for Multiple Root Layouts
Some apps don't have a single root layout. If you use route groups like app/(shop)/layout.tsx and app/(admin)/layout.tsx, or your top-level layout lives under a dynamic segment like app/[country]/layout.tsx, there's no obvious layout for an unmatched URL to render in.
For that, Next.js has an experimental global-not-found.tsx. Enable it in your config:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
globalNotFound: true,
},
};
export default nextConfig;
Then create the file at the root of app:
// app/global-not-found.tsx
import "./globals.css";
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "404 - Page Not Found",
description: "The page you are looking for does not exist.",
};
export default function GlobalNotFound() {
return (
<html lang="en">
<body className="grid min-h-screen place-items-center">
<main className="text-center">
<h1 className="text-3xl font-bold">Page not found</h1>
<Link href="/" className="mt-6 inline-block underline">
Go home
</Link>
</main>
</body>
</html>
);
}
Unlike not-found.tsx, this file bypasses your layouts completely, so it must render its own <html> and <body> and import any global CSS and fonts it needs. It also supports metadata exports. Since it skips rendering entirely for unmatched routes, it's fast, which is a nice property for the page bots hit most.
If your app has one root layout, you don't need this. Stick with app/not-found.tsx.
Related: forbidden() and unauthorized()
notFound() is for resources that don't exist. If a resource exists but the user can't see it, Next.js has matching APIs, forbidden() and unauthorized(), with their own forbidden.tsx and unauthorized.tsx files. Some teams still deliberately return a 404 for private resources so they don't reveal that something exists. Both approaches are valid; just be consistent.
A Checklist for Useful 404 Pages
- Say clearly that the page doesn't exist, in plain language.
- Keep your normal navigation visible (the root
not-found.tsxdoes this by default). - Offer at least one next step: home, search, or popular content.
- Use section-specific 404s where you can suggest related content.
- Don't turn real errors into 404s. A failed database query is not a missing page.
- Verify the status code with
curl -Iagainst a production build.
npm run build && npm run start
curl -I http://localhost:3000/this-does-not-exist
# HTTP/1.1 404 Not Found
Conclusion
app/not-found.tsx handles every URL your app doesn't recognize, and notFound() lets you say "this route matched, but the thing doesn't exist". Nest not-found.tsx files to keep section layouts in place and offer relevant suggestions, keep real errors separate from missing content, and pay attention to streaming if the HTTP status code matters to you. With those pieces in place, a 404 becomes a small detour instead of the end of a visit.


