50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 25, 2026·8 min read

How Next.js Handles 404 Pages — and How to Make Yours Actually Useful

The default Next.js 404 is a dead end. Here's how the routing works under the hood, and how to build a 404 that helps users instead of losing them.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

How Next.js Handles 404 Pages — and How to Make Yours Actually Useful

Every app has them. Users mistype URLs, links rot, someone copy-pastes the wrong thing into Slack — and suddenly they're staring at a 404. The question is whether that 404 is a dead end or a second chance to keep them in your app.

Next.js gives you the tools to do this well, but the defaults are pretty bare. Let's walk through exactly how 404 handling works in the App Router, what files you need, where most people get it wrong, and what a genuinely useful 404 page looks like.

How Next.js decides to show a 404

In the App Router, a 404 gets triggered in one of three ways. First: the user navigates to a path that doesn't match any route segment in your app directory — there's simply no page.tsx there. Second: a dynamic route (like /blog/[slug]) matches the segment pattern but your code calls notFound() explicitly, usually after failing to find the resource in your database. Third: a parallel or intercepting route falls through without a match.

All three eventually render the same thing: Next.js looks for the nearest not-found.tsx in the component tree, walking up from the current segment. If it finds one, it renders it. If it doesn't find one before hitting the root, it falls back to the built-in Next.js 404 page — which is functional but looks like you forgot to finish building your app.

The not-found.tsx file at the root of your app directory (inside /app) becomes your global 404. Anything more nested will only catch 404s within that segment's subtree.

The minimum setup you actually need

Create app/not-found.tsx and you're covered for the global case. That file doesn't receive any props — it's a plain Server Component by default. Here's the simplest version that at least doesn't embarrass you:

// app/not-found.tsx
import Link from 'next/link'

export default function NotFound() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
      <h1 className="text-4xl font-bold">404</h1>
      <p className="text-muted-foreground">
        This page doesn't exist or was moved.
      </p>
      <Link
        href="/"
        className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground"
      >
        Go home
      </Link>
    </main>
  )
}

export const metadata = {
  title: '404 — Page Not Found',
}

Notice the metadata export — don't forget this. Your 404 page should have a proper title so users and browser history reflect what happened. The default Next.js one handles this, but once you override it, that's on you.

Triggering notFound() from dynamic routes

The more interesting case is when you have a route like /blog/[slug] and the slug exists as a valid URL pattern, but there's no matching post in your database. If you don't explicitly tell Next.js this is a 404, it'll render whatever your page.tsx returns — which might be blank, might throw, or might show 'undefined' in production. We've seen all three. None are great.

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/posts'

interface Props {
  params: { slug: string }
}

export default async function BlogPost({ params }: Props) {
  const post = await getPostBySlug(params.slug)

  // This is the key line. Call it early, call it explicitly.
  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

The notFound() function throws internally (it's actually a special error that Next.js catches), so anything after it won't execute. TypeScript doesn't know this unless you're on a recent enough Next.js version, so you might still get type errors about post possibly being undefined below that call. Annotating the type after the check or using a type assertion is fine here — this is one of the few places where a non-null assertion (post!) is actually warranted.

Segment-level not-found.tsx: when it's worth it

You can create a not-found.tsx at any level of your route tree. So if you want /dashboard/settings/* to show a different 404 than /blog/*, you can. Most apps don't need this, but there are legitimate cases:

  • Admin sections where a missing resource should show something different from a public-facing 404
  • Tenant-specific routing where you want to include tenant branding in the 404
  • Docs sites where the 404 should link to a search UI relevant to that section
  • Any place where the layout context matters — segment-level not-found.tsx renders inside the nearest layout, global not-found.tsx does not

That last point trips people up. Your root app/not-found.tsx renders without your root layout wrapped around it. That means no navbar, no footer, no providers — unless you explicitly include them. If your app has a ThemeProvider or a ToastProvider at the root layout, your global 404 won't have them. Keep this in mind when you're adding interactivity.

Building a 404 that actually helps

A 404 page that just says '404 Not Found' and a home link is fine. A 404 page that actively helps users find what they were looking for is better. Here's what we usually add:

// app/not-found.tsx
import Link from 'next/link'
import { Search } from 'lucide-react'

const HELPFUL_LINKS = [
  { label: 'Browse all posts', href: '/blog' },
  { label: 'Documentation', href: '/docs' },
  { label: 'Contact support', href: '/support' },
]

export default function NotFound() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-8 p-8 text-center">
      <div className="space-y-2">
        <p className="text-sm font-medium uppercase tracking-widest text-muted-foreground">
          404
        </p>
        <h1 className="text-3xl font-bold tracking-tight">
          We lost this page
        </h1>
        <p className="max-w-md text-muted-foreground">
          It may have been moved, deleted, or you might have followed a broken link.
        </p>
      </div>

      {/* Search as the primary recovery action */}
      <form action="/search" method="GET" className="flex w-full max-w-sm gap-2">
        <input
          type="search"
          name="q"
          placeholder="Search for something..."
          className="flex-1 rounded-md border bg-background px-3 py-2 text-sm"
        />
        <button
          type="submit"
          className="rounded-md bg-primary px-3 py-2 text-primary-foreground"
        >
          <Search className="h-4 w-4" />
        </button>
      </form>

      {/* Secondary: curated helpful links */}
      <nav className="flex flex-col gap-2">
        {HELPFUL_LINKS.map((link) => (
          <Link
            key={link.href}
            href={link.href}
            className="text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
          >
            {link.label}
          </Link>
        ))}
      </nav>

      <Link
        href="/"
        className="text-xs text-muted-foreground underline-offset-4 hover:underline"
      >
        ← Back to home
      </Link>
    </main>
  )
}

export const metadata = {
  title: '404 — Page Not Found',
  robots: { index: false },
}

A few things in there worth calling out. The search form is a plain HTML form pointing at /search — no JavaScript needed. If you don't have a search page yet, skip it, but it's usually the most useful recovery action you can give someone. The robots metadata tells search engines not to index the 404 page itself, which is correct behavior — you don't want Googlebot crawling your error state.

Logging 404s and catching patterns

Here's something we started doing after noticing a spike in 404s on a production app: logging them. Not in a scary 'every hit goes to Sentry' way — just enough to catch patterns. If 200 users hit /dashbord (missing 'o') in a week, that's a typo in a link somewhere you need to fix, not 200 confused users.

The tricky part is that not-found.tsx is a Server Component — it doesn't run in a browser, it renders on the server. You can do server-side logging directly in the component, or log from middleware if you want to catch 404s before they even hit the component tree:

// middleware.ts — log 404s passively
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // We can't know here if the route will 404 — only after rendering
  // So handle this at the not-found component level instead
  return NextResponse.next()
}

// Better: log in not-found.tsx itself (Server Component, so this runs server-side)
// app/not-found.tsx
import { headers } from 'next/headers'

export default async function NotFound() {
  // Grab the URL that caused the 404
  const headersList = await headers()
  const referer = headersList.get('referer')
  const path = headersList.get('x-invoke-path') ?? 'unknown'

  // Log to your preferred destination
  // In production we'd send this to a simple logging table or PostHog
  if (process.env.NODE_ENV === 'production') {
    console.error(`[404] path=${path} referer=${referer ?? 'direct'}`)
  }

  // ... rest of the component
  return <div>...</div>
}

The x-invoke-path header isn't always reliable depending on your deployment setup — on Vercel it works well, on self-hosted you might need a different approach. But even just logging the referer tells you where bad links are coming from, which is usually more actionable than the URL itself.

What about redirect-based recovery?

Sometimes you know where the user was trying to go. You renamed /blog to /posts, or you restructured your docs. In these cases, a redirect in next.config.js or middleware is cleaner than a 404:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async redirects() {
    return [
      {
        source: '/blog/:slug',
        destination: '/posts/:slug',
        permanent: true, // 308 — tells Google this is the new URL
      },
      {
        source: '/docs/v1/:path*',
        destination: '/docs/:path*',
        permanent: false, // 307 — use this if you're not 100% sure yet
      },
    ]
  },
}

export default nextConfig

Use permanent: true (308) only when you're sure the old URL is gone forever. Google will update its index. Use permanent: false (307) when you're still figuring it out or the redirect is conditional. We've definitely shipped a permanent: true too early and regretted it — 308s get cached aggressively by browsers.

Redirects should live in next.config.js for static patterns. If you need database-driven redirects (e.g., a user deleted a page and set a forwarding URL), handle those in middleware where you can query or check a cache.

The pages router still exists, briefly

If you're on the Pages Router (or supporting a mixed app during migration), the 404 file lives at pages/404.tsx. Same idea, different convention. It won't fire the notFound() function from next/navigation — that's App Router only. In Pages Router you just return nothing or redirect from getStaticProps / getServerSideProps.

If you're building something new, use the App Router. If you're maintaining a Pages Router app, pages/404.tsx is the file you want. They don't share the same 404 handling, which matters if you have a mixed migration in progress — test both.

Quick checklist before you ship

  • app/not-found.tsx exists and doesn't look like an afterthought
  • Metadata title is set and robots: { index: false } is included
  • You're calling notFound() explicitly in dynamic routes when data isn't found
  • The 404 page has at least one useful action — search, home link, or curated suggestions
  • If you renamed or restructured routes, redirects are in place before removing the old ones
  • Root layout wrapping: if your 404 needs providers or global styles, either add them directly or use a segment-level not-found.tsx inside a layout

Our templates on peal.dev all ship with a not-found.tsx that's wired up correctly — including the metadata, the layout-aware rendering, and notFound() calls in the dynamic route examples — so you're not starting from scratch on this stuff every time.

404 pages are one of those things that take maybe an hour to do properly and most developers skip because they're focused on the happy path. Don't skip it. Someone will hit that page on launch day — probably while you're watching your analytics — and you'll be glad it doesn't just say 'This page could not be found.'

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders