Error handling in Next.js is split across two worlds. On the client side, you have error boundaries with error.tsx. On the server side, you have try/catch in API routes and server components. In production, you have errors that exist in neither world — they only appear under real traffic, real data, and real conditions that your local development never simulates.
This guide covers all three: client error boundaries, server-side error handling, and the monitoring layer that catches what the first two miss.
Client-Side: error.tsx
The App Router in Next.js uses a file called error.tsx to create error boundaries. Place it in any route segment, and it catches runtime errors from its sibling page.tsx and all nested child routes.
// app/dashboard/error.tsx
'use client' // Error boundaries must be client components
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="p-6 text-center">
<h2 className="text-xl font-bold">Something went wrong</h2>
<p className="mt-2 text-gray-600">
{error.message || 'An unexpected error occurred'}
</p>
<button
onClick={reset}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
>
Try again
</button>
</div>
)
}Key details:
- Must be a client component. Add
'use client'at the top. React error boundaries only work on the client. - Receives
errorandreset. The error object contains the message (sanitized in production — Next.js strips details to avoid leaking server information). Thereset()function re-renders the segment, which is useful for transient errors. - Scoped to its segment. An
error.tsxinapp/dashboard/catches errors in the dashboard pages but not inapp/settings/. The rest of the app continues working. - Does not catch errors in the layout. If the error is in
layout.tsxof the same segment,error.tsxcannot catch it. The error bubbles up to the parent segment's error boundary.
Nested error boundaries
You can place error.tsx at multiple levels for granular control:
app/
error.tsx // Catches errors from app-level pages
dashboard/
error.tsx // Catches errors from dashboard pages
analytics/
error.tsx // Catches errors from analytics pages only
page.tsx
settings/
page.tsx // Falls back to dashboard/error.tsxErrors bubble up through the tree until an error.tsx catches them. This means you can have specific error UIs for critical sections (checkout, dashboard) and a generic fallback for everything else.
Global Error Boundary: global-error.tsx
There is one error that error.tsx cannot catch: errors in the root layout (app/layout.tsx). For this, Next.js provides global-error.tsx:
// app/global-error.tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<div className="p-6 text-center">
<h2 className="text-xl font-bold">Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
</body>
</html>
)
}Important:global-error.tsx replaces the root layout when triggered. That is why it must include its own <html> and <body> tags. Your normal layout styles, navigation, and providers will not be available.
In practice, global-error.tsx should be a simple, standalone page with no external dependencies. If the root layout crashed, you cannot assume any of your design system or context providers are working.
Server-Side: API Route Error Handling
error.tsx handles client-side rendering errors. But your API routes run on the server and need their own error handling pattern.
// lib/api-error.ts
export class AppError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: Record<string, string>[]
) {
super(message)
this.name = 'AppError'
}
}
export function withErrorHandling(
handler: (request: Request) => Promise<Response>
) {
return async (request: Request): Promise<Response> => {
try {
return await handler(request)
} catch (error) {
if (error instanceof AppError) {
return Response.json(
{
error: {
code: error.code,
message: error.message,
details: error.details,
},
},
{ status: error.statusCode }
)
}
console.error('Unhandled error:', error)
return Response.json(
{ error: { code: 'INTERNAL_ERROR', message: 'Something went wrong' } },
{ status: 500 }
)
}
}
}// app/api/users/[id]/route.ts
import { AppError, withErrorHandling } from '@/lib/api-error'
export const GET = withErrorHandling(async (request: Request) => {
const id = request.url.split('/').pop()
const user = await db.user.findById(id)
if (!user) {
throw new AppError(404, 'USER_NOT_FOUND', `User ${id} not found`)
}
return Response.json(user)
})The withErrorHandling wrapper gives you consistent error handling across all API routes without repeating try/catch in every file. Known errors become AppError throws with appropriate status codes. Unknown errors are caught, logged, and returned as generic 500s.
Server Components: Error Handling Patterns
Server components in Next.js can also throw errors. When they do, the nearest error.tsx boundary catches them on the client side. But the server-side error itself needs handling:
// app/dashboard/page.tsx (server component)
async function DashboardPage() {
let analytics
try {
analytics = await fetchAnalytics()
} catch (error) {
// Option 1: Show a fallback in the same page
console.error('Failed to fetch analytics:', error)
analytics = null
}
if (!analytics) {
return <p>Analytics data is temporarily unavailable.</p>
}
return <AnalyticsDashboard data={analytics} />
}Or, if the error is critical and you want the error boundary to handle it:
// app/dashboard/page.tsx
async function DashboardPage() {
const analytics = await fetchAnalytics() // Let it throw
return <AnalyticsDashboard data={analytics} />
}
// error.tsx in this segment will catch the thrown errorThe decision depends on criticality. If the analytics data is a nice-to-have, catch the error and show a fallback. If it is the entire point of the page, let it throw and show the error boundary.
The Production Gap: Errors That Only Appear Live
You have error boundaries for the client. You have try/catch for the server. Your tests pass. You deploy. And then errors happen that none of your local testing anticipated.
Why? Because production is different from development in ways that matter:
- Real data is messy. That field you assumed was always a string? Someone has a null in the database. That date you parse? Someone entered "2026-13-45".
- Concurrency breaks things. Two users editing the same resource. A webhook arriving before the database write commits. A queue processing faster than the downstream service can handle.
- Third-party services fail. Stripe has a blip for 30 seconds. Your email provider rate-limits you. The geocoding API returns HTML instead of JSON.
- Cold starts change timing. Your function takes 200ms locally but 2000ms on the first cold invocation. Timeouts that never fire locally start firing in production.
- Environment differences. Different Node.js versions, different memory limits, different network configurations, different SSL certificates.
Your error handling code catches these errors when they happen. But nobody is watching. The console.error goes to Vercel logs that expire in an hour. The 500 response goes to a user who retries and it works the second time. The error rate climbs from 0.1% to 2% over a week and nobody notices.
Monitoring: Catching What Error Handling Misses
This is where monitoring complements error handling. Error handling is the code that responds to individual failures. Monitoring is the system that watches the aggregate and alerts you when patterns emerge.
Nurbak Watch fills this role for Next.js applications. It uses the instrumentation.ts hook to run inside your server and track every API request automatically:
// instrumentation.ts
import { initWatch } from '@nurbak/watch'
export function register() {
initWatch({
apiKey: process.env.NURBAK_WATCH_KEY,
})
}Five lines. After deploying, you get:
- Every API route auto-discovered and tracked
- Per-endpoint response times, error rates, and status codes
- Alerts via Slack, email, or WhatsApp in under 10 seconds when error rates spike
- Historical data to spot gradual degradation
$29/month flat, free during beta. Your error.tsx handles what the user sees. Your try/catch handles what the server does. Monitoring handles knowing it happened at all.

