Show 404 pages with notFound and not-found.tsx, and catch crashes with error.tsx boundaries that let users retry.
Why: when a record doesn’t exist, call notFound(). It stops rendering and shows the nearest not-found.tsx. Cleaner than returning ad-hoc "not found" markup, and it sends the correct 404 status.
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/posts'
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post) notFound() // -> renders not-found.tsx
return <h1>{post.title}</h1>
}Why: add a not-found.tsx in a folder to control what a 404 looks like for that section. Place one at the app root for a site-wide 404, or deeper for section-specific ones.
// app/blog/[slug]/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>Post not found</h2>
<Link href="/blog">Back to the blog</Link>
</div>
)
}Why: an error.tsx file is an error boundary — if a page or its children throw, Next.js shows this fallback instead of a blank screen. It must be a Client Component, and it receives an unstable_retry function to re-attempt rendering.
// app/dashboard/error.tsx
'use client'
import { useEffect } from 'react'
export default function ErrorPage({
error,
unstable_retry,
}: {
error: Error & { digest?: string }
unstable_retry: () => void
}) {
useEffect(() => {
console.error(error) // report it to your logging service
}, [error])
return (
<div>
<h2>Something went wrong.</h2>
<button onClick={() => unstable_retry()}>Try again</button>
</div>
)
}Why: an error.tsx catches throws from its own segment and the segments below it, then bubbles to the nearest parent boundary. So place boundaries at the levels where you want isolated recovery — a broken widget shouldn’t take down the whole app. Note: error boundaries only catch errors during RENDER. Errors inside event handlers like onClick or onSubmit are NOT caught here — handle those with try/catch and state.
app/dashboard/error.tsx # catches errors in /dashboard and its children
app/error.tsx # catches anything not caught deeperWhy: the root layout sits above every error boundary, so to catch a crash in it you need global-error.tsx at the app root. Because it replaces the root layout, it must render its own <html> and <body>.
// app/global-error.tsx
'use client'
export default function GlobalError({
unstable_retry,
}: {
error: Error
unstable_retry: () => void
}) {
return (
<html>
<body>
<h2>Something went wrong.</h2>
<button onClick={() => unstable_retry()}>Try again</button>
</body>
</html>
)
}