1. Introduction đ
Things will fail â a database times out, an API returns garbage, a user hits a route that doesn't exist. What separates a polished app from a broken one is how gracefully it handles those moments. This tutorial covers the full lifecycle: loading.tsx while data is in flight, error.tsx when something breaks, and not-found.tsx when a route simply isn't there.
Information
2. Understanding Errors in Next.js đ¤
Errors in the app router fall into a few categories, each handled by a different mechanism: rendering errors (caught by error.tsx), missing routes (not-found.tsx), and errors you catch and handle manually inside Route Handlers or Server Actions.
3. Types of Errors đ§
| Type | Example | Handled by |
|---|---|---|
| Rendering error | A component throws during render | error.tsx |
| Not found | User visits a non-existent post ID | not-found.tsx |
| Root layout error | Error in the root layout itself | global-error.tsx |
| API/data error | A fetch or database call fails | Manual try/catch |
4. Error Boundaries đĄī¸
Next.js automatically wraps each route segment in a React error boundary when an error.tsx file is present, catching any error thrown during rendering in that segment or its children.
Tip
5. error.tsx đ§¯
An error.tsx file defines the fallback UI for its route segment. It must be a Client Component, since it uses React state and an event handler to trigger recovery.
app/dashboard/error.tsx
'use client';
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}Warning
6. global-error.tsx đ
global-error.tsx catches errors thrown in the root layout itself â a place a regular error.tsx can't reach, since it lives inside the layout it would need to replace.
app/global-error.tsx
'use client';
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
return (
<html>
<body>
<h2>A critical error occurred</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
);
}Important
7. not-found.tsx đ
A not-found.tsx file renders whenever the notFound() function is called, or when a URL doesn't match any route.
app/blog/[slug]/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>Post not found</h2>
<p>The post you're looking for doesn't exist.</p>
</div>
);
}app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) notFound();
return <Article post={post} />;
}8. loading.tsx âŗ
A loading.tsx file automatically wraps its route segment (and any nested segments) in a Suspense boundary, showing instantly while the async page component resolves.
app/dashboard/loading.tsx
export default function Loading() {
return <p>Loading dashboard...</p>;
}9. Loading UI đŧī¸
Good loading UI should roughly match the shape of the content it's replacing, so the page doesn't visually "jump" once real content arrives.
Best Practice
10. Skeleton Screens đ
A skeleton screen shows gray placeholder blocks in the shape of the eventual content â generally perceived as faster and less jarring than a plain spinner.
app/dashboard/loading.tsx
export default function Loading() {
return (
<div>
<div className="h-6 w-1/3 animate-pulse rounded bg-gray-200" />
<div className="mt-2 h-4 w-full animate-pulse rounded bg-gray-200" />
</div>
);
}11. Suspense Boundaries â¸ī¸
For more granular control than a page-level loading.tsx, wrap individual slow components in their own Suspense boundary, letting fast parts of the page render immediately.
app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<Header />
<Suspense fallback={<StatsSkeleton />}>
<SlowStats />
</Suspense>
</div>
);
}12. Async Error Handling â ī¸
An error thrown inside an async Server Component is automatically caught by the nearest error.tsx boundary â no manual try/catch is required at that layer.
app/dashboard/page.tsx
export default async function Dashboard() {
const data = await fetchDashboardData(); // if this throws, error.tsx catches it
return <DashboardView data={data} />;
}13. Route Handler Errors đ
Unlike page rendering, Route Handlers have no automatic error boundary â an uncaught error results in a generic 500 response. Always wrap logic in try/catch.
app/api/users/route.ts
export async function GET() {
try {
const users = await db.user.findMany();
return Response.json(users);
} catch (error) {
console.error(error);
return Response.json({ error: 'Failed to fetch users' }, { status: 500 });
}
}14. Server Action Errors đŦ
Errors thrown inside a Server Action bubble up to the nearest error.tsx if called directly during render, but when called from a form submission, it's usually clearer to return a structured error object instead.
app/actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title');
if (!title) {
return { error: 'Title is required' };
}
try {
await db.post.create({ data: { title: title.toString() } });
return { success: true };
} catch {
return { error: 'Failed to create post' };
}
}15. API Error Handling đĄ
When calling an external API with fetch, remember that fetch does not throw on a 4xx/5xx response â you must check response.ok yourself.
lib/data.ts
export async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) {
throw new Error(`Failed to fetch user: ${res.status}`);
}
return res.json();
}16. Data Fetching Errors đ
When a data source fails, decide deliberately between three outcomes: throw (triggering error.tsx), call notFound() (if the data simply doesn't exist), or return a fallback value (if partial content is acceptable).
17. Validation Errors â
Validation errors â a malformed email, a missing required field â should generally be returned as data, not thrown as exceptions, so they can be displayed inline next to the relevant form field.
app/actions.ts
'use server';
import { z } from 'zod';
const schema = z.object({ email: z.string().email() });
export async function subscribe(formData: FormData) {
const result = schema.safeParse({ email: formData.get('email') });
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await addSubscriber(result.data.email);
return { success: true };
}18. Authentication Errors đ
An unauthenticated request should typically redirect to a login page (for page views) or return a 401 JSON response (for API calls) â the right choice depends on who is calling.
app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
export default async function Dashboard() {
const session = await getSession();
if (!session) redirect('/login');
return <DashboardView user={session.user} />;
}19. Network Errors đļ
A failed network request â a timeout, a DNS failure â should be caught and surfaced as a clear, human-readable message rather than a raw stack trace or a blank page.
lib/data.ts
export async function safeFetch(url: string) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return await res.json();
} catch (err) {
throw new Error('Unable to reach the server. Please try again.');
}
}20. Redirect Errors âĒī¸
Calling redirect() from next/navigation works by internally throwing a special error that Next.js recognizes â never wrap a call to redirect() in a try/catch that swallows it.
Caution
21. Custom Error Pages đ¨
Beyond the default fallback text, a well-designed error.tsx or not-found.tsx should match your app's branding and offer a clear next step â a link home, a retry button, a support contact.
app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h1>404 â Page Not Found</h1>
<p>We couldn't find what you were looking for.</p>
<Link href="/">Return home</Link>
</div>
);
}22. Logging Errors đ
Inside error.tsx, use a useEffect to log the error to a monitoring service as soon as the boundary renders, so failures are captured even if the user never reports them.
app/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
useEffect(() => {
logErrorToService(error);
}, [error]);
return <p>Something went wrong.</p>;
}23. Monitoring Errors đ
Tools like Sentry or Datadog capture errors from both the client and server in production, with stack traces, breadcrumbs, and alerting â far more actionable than console.log alone.
24. Debugging đ
- Reproduce the error locally with next dev, where full stack traces are shown.
- Check whether the error originates in a Server Component, Client Component, Route Handler, or Server Action.
- Add targeted logging around the suspected failure point.
- Confirm the fix by triggering the exact same code path that failed originally.
25. Development vs Production Errors đ
In development, Next.js shows a detailed overlay with the full stack trace and source location. In production, that detail is hidden from the user by default â only a generic message and a digest ID are shown, to avoid leaking internals.
Warning
26. Graceful Recovery đ
The reset() function passed to error.tsx attempts to re-render the segment without a full page reload â ideal for transient errors like a momentary network blip.
app/error.tsx
'use client';
export default function Error({ reset }: { reset: () => void }) {
return (
<div>
<p>Something went wrong.</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}27. User Experience Best Practices â¨
- Always give the user a next step â retry, go home, contact support.
- Keep error messages calm and non-technical; save the stack trace for your logs.
- Match loading skeletons closely to real content to avoid layout shift.
- Distinguish "not found" from "something broke" â they call for different messaging.
28. Common Mistakes đĢ
29. Frequently Asked Questions â
No â error boundaries only catch errors thrown during rendering. Errors inside onClick or other event handlers need their own try/catch.
error.tsx handles unexpected failures (something broke), while not-found.tsx handles the expected case of a resource simply not existing.
Yes â you can place a loading.tsx in any route segment, and each one automatically wraps just that segment and its children in a Suspense boundary.