Next.js 404 Page: not-found.tsx, notFound(), and What to Put on It
Published: October 8, 2026 · 7 min read
Build a custom Next.js App Router 404 page: app/not-found.tsx, notFound() for missing data, per-folder 404s, noindex, what to show, and the [locale] caveat.
Replace the default Next.js 404 page with your own design
You built a site in Next.js, opened a URL that does not exist, and got a plain white screen with "404" and "This page could not be found." It does not match your site, and visitors have no idea where to go next.
Here is the short answer. Put one not-found.tsx file directly under the app folder, and it becomes your 404 page.
When a post or product cannot be found, call notFound() inside the page. Next.js switches to that same 404 page.
This guide covers:
The basic 404 page with app/not-found.tsx
Showing the 404 page with notFound() when data is missing
A different 404 page per folder, such as the blog
What to put on a 404 page, plus a subtle animation
The caveat for multilingual sites using [locale]
Three reasons to design your 404 page
People land on a 404 page through a broken link or a typo. They came because they were interested, and they hit a dead end.
Fewer exits: links to the home page or popular pages keep them reading
A consistent brand: with your header and colors in place, the site does not look broken
Clear signals to search engines: returning a real 404 keeps dead URLs out of search results
A nice design means little if a missing post still comes back as a normal page. This guide builds both the look and the mechanics.
Step 1: Create a 404 page with app/not-found.tsx
not-found.tsx is a special file name in Next.js. Placed directly under app, it renders for any URL that matches no route.
It works as a Server Component. It renders inside your root layout (app/layout.tsx), so your header and footer come along.
import Link from "next/link";
export default function NotFound() {
return (
<main className="mx-auto flex min-h-[70vh] max-w-xl flex-col items-center justify-center px-6 py-24 text-center">
<p className="text-sm font-semibold tracking-widest text-zinc-500">404</p>
<h1 className="mt-3 text-3xl font-semibold text-zinc-900">Page not found</h1>
<p className="mt-4 text-zinc-600">
The URL may have changed, or the page may have been removed.
</p>
<Link
href="/"
className="mt-8 inline-flex min-h-11 items-center rounded-full bg-zinc-900 px-6 text-sm font-medium text-white hover:bg-zinc-700"
>
Back to home
</Link>
</main>
);
}Give the 404 page a title with metadata
not-found.tsx can export metadata just like a page. When the browser tab says "Page not found", visitors understand what happened at a glance.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Page not found",
};The 404 status and noindex are added for you
When not-found.tsx renders, Next.js normally responds with a 404 status code, the number a server uses to say whether a page exists.
It also injects a noindex robots meta tag so the page stays out of search results. You do not need to configure robots yourself.
The exception is when the response has already started streaming, for example because of a loading.tsx, before the 404 happens. The status is then 200, but the noindex tag is still added.
Step 2: Show the 404 page with notFound() when data is missing
A blog route like app/blog/[slug]/page.tsx matches any string. Even a URL for a post that does not exist opens this page.
So call notFound() from next/navigation when the data is missing. Rendering stops right there and the 404 page takes over.
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { cache } from "react";
import { getPostBySlug } from "@/lib/posts"; // returns undefined when the post does not exist
// Share one fetch between generateMetadata and the page
const getPost = cache(getPostBySlug);
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return { title: post.title, description: post.excerpt };
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return (
<article className="mx-auto max-w-2xl px-6 py-24">
<h1 className="text-3xl font-semibold">{post.title}</h1>
<div className="mt-8 leading-relaxed text-zinc-700">{post.body}</div>
</article>
);
}Key points of the notFound() code
In Next.js 15, params is a Promise, so await it before reading slug
Nothing after notFound() runs, and TypeScript treats post as defined from there on
You can call notFound() inside generateMetadata the same way
Wrapping the fetcher in React's cache means the title and the body share a single fetch
A separate 404 page just for the blog
You can place not-found.tsx in any folder. With app/blog/not-found.tsx, calls to notFound() inside the blog use that file instead.
The closest file wins, so you can show guidance that fits the section, such as a link back to the post list. Unmatched URLs, however, are handled by the not-found.tsx directly under app (unless you opt into the experimental global-not-found.tsx from 15.4).
import Link from "next/link";
export default function BlogNotFound() {
return (
<main className="mx-auto max-w-xl px-6 py-24 text-center">
<h1 className="text-2xl font-semibold text-zinc-900">Post not found</h1>
<p className="mt-4 text-zinc-600">The post URL may have changed, or it is no longer published.</p>
<Link
href="/blog"
className="mt-8 inline-flex min-h-11 items-center rounded-full bg-zinc-900 px-6 text-sm font-medium text-white"
>
All posts
</Link>
</main>
);
}Step 3: Decide what goes on your 404 page
A 404 page exists to send lost visitors somewhere useful. Before any fancy effect, get these three in place:
A short heading that says what happened ("Page not found")
A button back to the home page
Site search, or links to your most-read pages
A search form that just submits with GET works fine in a Server Component. Point /search at your own search page.
import Link from "next/link";
const popular = [
{ href: "/blog", label: "All blog posts" },
{ href: "/pricing", label: "Pricing" },
{ href: "/contact", label: "Contact" },
];
export default function NotFound() {
return (
<main className="mx-auto max-w-xl px-6 py-24 text-center">
<p className="text-sm font-semibold tracking-widest text-zinc-500">404</p>
<h1 className="mt-3 text-3xl font-semibold text-zinc-900">Page not found</h1>
<p className="mt-4 text-zinc-600">The URL may have changed, or the page may have been removed.</p>
<form action="/search" method="get" role="search" className="mt-8 flex gap-2">
<label htmlFor="q" className="sr-only">Search this site</label>
<input
id="q"
name="q"
type="search"
placeholder="Search by keyword"
className="min-h-11 flex-1 rounded-full border border-zinc-300 px-4 text-base"
/>
<button type="submit" className="min-h-11 rounded-full bg-zinc-900 px-5 text-sm font-medium text-white">
Search
</button>
</form>
<ul className="mt-10 space-y-1 text-sm">
{popular.map((item) => (
<li key={item.href}>
<Link href={item.href} className="inline-flex min-h-11 items-center underline underline-offset-4">
{item.label}
</Link>
</li>
))}
</ul>
<Link
href="/"
className="mt-8 inline-flex min-h-11 items-center rounded-full border border-zinc-300 px-6 text-sm font-medium"
>
Back to home
</Link>
</main>
);
}Write 404 page copy that does not blame the reader
"You typed the wrong URL" sounds like the visitor's fault, yet broken links often come from the site itself. Stick to the facts: "not found", "may have moved".
If you add an illustration or a joke, keep the way back visible without scrolling.
Step 4: Add a subtle animation to the 404 page
Letting a big 404 numeral float up into place is enough to change the feel. Run it once and keep it around half a second.
Use Motion (formerly Framer Motion). Put only the animated part in its own file and add "use client", the marker for browser-side code, at the top.
"use client";
import { motion, useReducedMotion } from "motion/react";
export function NotFoundNumber() {
const reduceMotion = useReducedMotion();
return (
<motion.p
aria-hidden="true"
initial={reduceMotion ? false : { opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="text-[8rem] font-semibold leading-none tracking-tight text-zinc-900 sm:text-[12rem]"
>
404
</motion.p>
);
}Key points of the 404 page animation
Keep not-found.tsx a Server Component and import only this piece
For people who prefer reduced motion, initial is false, so the numeral is simply shown
The big numeral is decoration, so it gets aria-hidden and the h1 does the talking
Motion basics are covered in https://design-layer.com/en/articles/nextjs-framer-motion-guide.
404 pages on multilingual sites with [locale]
Sites with the language at the start of the URL put their pages under app/[locale]. That changes how the 404 page behaves.
app/[locale]/not-found.tsx renders when notFound() is called inside that segment, but it is not used for URLs that match no route.
The fix is a catch-all page that receives every remaining URL and calls notFound(). Multilingual libraries such as next-intl document this same approach.
Note that not-found.tsx does not receive params. Pick the display language the way your i18n library recommends.
import { notFound } from "next/navigation";
// Catch every unmatched URL here and render the [locale] not-found.tsx
export default function CatchAllPage() {
notFound();
}Common 404 page mistakes and pre-launch checks
Returning an empty page when data is missing. Without notFound(), a blank page reaches search engines as a 200
Auto-redirecting from the 404 page to home. Visitors lose track of what happened, and the missing page is not signaled clearly
No link back, or one buried at the bottom
Adding "use client" to the whole 404 page. Split out only the animated part
Returning 404 for pages that merely moved. If there is a new location, redirect with redirects in next.config
Before launch, open a URL that does not exist and check the result, ideally on a production build (next build).
Check the status with something like curl -I https://example.com/no-such-page to confirm a 404 comes back.
Copy a finished 404 page instead

The steps above give you a working 404 page. Polishing the look, though, takes time:
A giant 404 on a paper-textured background
A statue cut-out layered in the center
Copy and accent color matched to your site
In that case you can check a finished 404 screen in the browser first, then bring its code into your Next.js app.
Statue 404 on DesignLayer is an editorial error page: a giant wordmark on textured paper with a marble statue cut-out in the center. Swap the copy, the statue image, and the accent color, then drop it into not-found.tsx.
Press Copy for Cursor on the detail page to copy the integration instructions, the live preview URL, the component TSX, and npm dependencies in one go. Paste them into Cursor or Claude and ask it to wire things up.
Browsing and live previews are free with no account or card, and free source copies are 3 per day (JST). https://design-layer.com/en/components
Next.js 404 page: common questions
In the App Router, put not-found.tsx directly under the app folder. It replaces pages/404.tsx from the Pages Router.
Summary: building a 404 page in Next.js
not-found.tsx directly under app becomes the 404 page for unmatched URLs
Call notFound() when data is missing. Next.js adds the 404 status and noindex
A not-found.tsx inside a folder gives that section its own 404 page
Start with a heading, a link home, and search or popular links
On [locale] sites, call notFound() from a catch-all page for the remaining URLs
Start by dropping the Step 1 code under app and opening a URL that does not exist.
The screen people see while data loads is covered in https://design-layer.com/en/articles/nextjs-loading-screen-guide.