{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreicszmr42njd6vpwxzikizthy45tiwqpjbht333mqvtmtt3777dcma",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mokqlm6rkll2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreidzahuamvpcnysrxejslyoyfgkeapvxe2dvdvfkteqfvxqplmp2uy"
},
"mimeType": "image/webp",
"size": 70790
},
"path": "/stacknotice/nextjs-15-error-handling-errortsx-server-actions-and-sentry-2026-ej1",
"publishedAt": "2026-06-18T11:20:17.000Z",
"site": "https://dev.to",
"tags": [
"nextjs",
"react",
"typescript",
"webdev",
"stacknotice.com/blog/nextjs-error-handling-2026",
"@sentry"
],
"textContent": "Proper error handling in Next.js 15 is spread across four different mechanisms that serve different purposes. Most guides cover one of them. This covers all four — and how they work together in a production app.\n\n## The Four Layers of Error Handling\n\n 1. **error.tsx** — Client component that catches rendering errors in a route segment\n 2. **global-error.tsx** — Catches errors in the root layout itself\n 3. **Server action errors** — Errors that happen during mutations and form submissions\n 4. **not-found.tsx** — Handles 404s from `notFound()` calls\n\n\n\nThese are separate concerns with separate solutions. Confusing them leads to errors that silently fail or crash the wrong boundary.\n\n## error.tsx — Route-Level Boundaries\n\nEvery folder in your `app/` directory can have its own `error.tsx`. When a component in that segment throws, Next.js renders the `error.tsx` instead.\n\n\n\n // app/dashboard/error.tsx\n 'use client'\n\n import { useEffect } from 'react'\n import * as Sentry from '@sentry/nextjs'\n\n interface ErrorProps {\n error: Error & { digest?: string }\n reset: () => void\n }\n\n export default function DashboardError({ error, reset }: ErrorProps) {\n useEffect(() => {\n Sentry.captureException(error, {\n tags: { section: 'dashboard', digest: error.digest },\n })\n }, [error])\n\n return (\n <div className=\"flex flex-col items-center justify-center min-h-[400px] gap-4\">\n <h2 className=\"text-xl font-semibold text-gray-900\">\n Something went wrong\n </h2>\n <p className=\"text-sm text-gray-500 max-w-md text-center\">\n {error.message || 'An unexpected error occurred.'}\n </p>\n <button\n onClick={reset}\n className=\"px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700\"\n >\n Try again\n </button>\n </div>\n )\n }\n\n\nThe `reset` function re-renders the segment. The `error.digest` is a server-side hash you can use to correlate client errors with server logs.\n\n> **Important:** `error.tsx` must include `'use client'`. Error boundaries in React require client-side code.\n\nThe `error.tsx` in `app/dashboard/` catches errors in `app/dashboard/page.tsx` and any nested routes. It does **not** catch errors in `app/dashboard/layout.tsx` itself.\n\n## global-error.tsx — Root Layout Boundary\n\n\n // app/global-error.tsx\n 'use client'\n\n export default function GlobalError({\n error,\n reset,\n }: {\n error: Error & { digest?: string }\n reset: () => void\n }) {\n return (\n <html>\n <body>\n <div className=\"flex flex-col items-center justify-center min-h-screen gap-4 p-8\">\n <h1 className=\"text-2xl font-bold\">Something went wrong</h1>\n <p className=\"text-gray-600 text-center max-w-md\">\n A critical error occurred. Please refresh the page.\n </p>\n <button\n onClick={reset}\n className=\"px-6 py-2 bg-red-600 text-white rounded-md hover:bg-red-700\"\n >\n Refresh\n </button>\n </div>\n </body>\n </html>\n )\n }\n\n\nNotice it includes `<html>` and `<body>` — it replaces the root layout entirely when it triggers. In development, Next.js shows its own error overlay instead.\n\n## not-found.tsx\n\n\n // app/blog/[slug]/page.tsx\n import { notFound } from 'next/navigation'\n import { getPost } from '@/lib/posts'\n\n export default async function BlogPost({\n params,\n }: {\n params: Promise<{ slug: string }>\n }) {\n const { slug } = await params\n const post = await getPost(slug)\n\n if (!post) {\n notFound()\n }\n\n return <article>{/* render post */}</article>\n }\n\n\n\n // app/not-found.tsx\n import Link from 'next/link'\n\n export default function NotFound() {\n return (\n <div className=\"flex flex-col items-center justify-center min-h-[60vh] gap-4\">\n <h2 className=\"text-4xl font-bold text-gray-900\">404</h2>\n <p className=\"text-gray-600\">The page you're looking for doesn't exist.</p>\n <Link href=\"/\" className=\"px-4 py-2 bg-gray-900 text-white rounded-md text-sm\">\n Go home\n </Link>\n </div>\n )\n }\n\n\nUnlike `error.tsx`, `not-found.tsx` is a Server Component by default. You can have one per route segment.\n\n## Server Action Errors\n\nThrowing inside a server action in production shows nothing useful to the client. The pattern that works is returning a typed result object instead:\n\n\n\n // lib/actions/auth.ts\n 'use server'\n\n type ActionResult<T> =\n | { success: true; data: T }\n | { success: false; error: string }\n\n export async function signIn(\n formData: FormData\n ): Promise<ActionResult<{ userId: string }>> {\n const email = formData.get('email') as string\n const password = formData.get('password') as string\n\n if (!email || !password) {\n return { success: false, error: 'Email and password are required' }\n }\n\n try {\n const user = await db.user.findUnique({ where: { email } })\n\n if (!user || !(await verifyPassword(password, user.passwordHash))) {\n return { success: false, error: 'Invalid credentials' }\n }\n\n return { success: true, data: { userId: user.id } }\n } catch (err) {\n console.error('SignIn error:', err)\n return { success: false, error: 'Authentication failed. Please try again.' }\n }\n }\n\n\nOn the client side, use `useActionState`:\n\n\n\n // app/login/page.tsx\n 'use client'\n\n import { useActionState } from 'react'\n import { signIn } from '@/lib/actions/auth'\n\n type State = { error?: string } | null\n\n export default function LoginPage() {\n const [state, formAction, isPending] = useActionState(\n async (_prev: State, formData: FormData): Promise<State> => {\n const result = await signIn(formData)\n if (!result.success) return { error: result.error }\n return null\n },\n null\n )\n\n return (\n <form action={formAction} className=\"flex flex-col gap-4 max-w-sm mx-auto\">\n <input name=\"email\" type=\"email\" placeholder=\"Email\" required className=\"border rounded-md px-3 py-2\" />\n <input name=\"password\" type=\"password\" placeholder=\"Password\" required className=\"border rounded-md px-3 py-2\" />\n {state?.error && (\n <p className=\"text-sm text-red-600\">{state.error}</p>\n )}\n <button\n type=\"submit\"\n disabled={isPending}\n className=\"px-4 py-2 bg-blue-600 text-white rounded-md disabled:opacity-50\"\n >\n {isPending ? 'Signing in...' : 'Sign in'}\n </button>\n </form>\n )\n }\n\n\n## Typed Errors with Discriminated Unions\n\nFor complex apps, typed error codes prevent string messages from getting out of control:\n\n\n\n // lib/errors.ts\n export type AppError =\n | { code: 'NOT_FOUND'; resource: string }\n | { code: 'UNAUTHORIZED'; reason: string }\n | { code: 'VALIDATION_FAILED'; fields: Record<string, string> }\n | { code: 'RATE_LIMITED'; retryAfter: number }\n | { code: 'INTERNAL'; message: string }\n\n export function getErrorMessage(error: AppError): string {\n switch (error.code) {\n case 'NOT_FOUND':\n return `${error.resource} not found`\n case 'UNAUTHORIZED':\n return `Unauthorized: ${error.reason}`\n case 'VALIDATION_FAILED':\n return Object.values(error.fields).join(', ')\n case 'RATE_LIMITED':\n return `Too many requests. Try again in ${error.retryAfter}s`\n case 'INTERNAL':\n return 'An unexpected error occurred'\n }\n }\n\n\nTypeScript enforces that you handle every error code — no silent fallthrough.\n\n## Sentry Integration\n\n\n npm install @sentry/nextjs\n npx @sentry/wizard@latest -i nextjs\n\n\n\n // sentry.client.config.ts\n import * as Sentry from '@sentry/nextjs'\n\n Sentry.init({\n dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,\n tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,\n replaysOnErrorSampleRate: 1.0,\n replaysSessionSampleRate: 0.05,\n integrations: [\n Sentry.replayIntegration({\n maskAllText: true,\n blockAllMedia: true,\n }),\n ],\n })\n\n\nFor server actions, capture before returning:\n\n\n\n import * as Sentry from '@sentry/nextjs'\n\n export async function createPost(formData: FormData) {\n try {\n // ...\n } catch (err) {\n Sentry.captureException(err, { tags: { action: 'createPost' } })\n return { ok: false, error: { code: 'INTERNAL' as const, message: 'Failed' } }\n }\n }\n\n\n## Handling Errors in Layouts\n\nIf `app/dashboard/layout.tsx` throws, the `app/dashboard/error.tsx` does **not** catch it. Use try/catch inside the layout:\n\n\n\n // app/dashboard/layout.tsx\n import { redirect } from 'next/navigation'\n import { getUser } from '@/lib/auth'\n\n export default async function DashboardLayout({\n children,\n }: {\n children: React.ReactNode\n }) {\n let user\n\n try {\n user = await getUser()\n } catch {\n redirect('/login')\n }\n\n if (!user) redirect('/login')\n\n return (\n <div className=\"min-h-screen bg-gray-50\">\n <nav>{/* nav */}</nav>\n <main className=\"container mx-auto py-8\">{children}</main>\n </div>\n )\n }\n\n\n## The Complete File Structure\n\n\n app/\n dashboard/\n layout.tsx ← shared layout\n page.tsx ← content (async Server Component)\n loading.tsx ← skeleton while page.tsx suspends\n error.tsx ← error boundary (Client Component)\n not-found.tsx ← 404 for this segment\n\n\nNext.js automatically wraps `page.tsx` in a `<Suspense>` boundary when `loading.tsx` is present, and in an error boundary when `error.tsx` is present.\n\n## Quick Reference\n\nScenario | Solution\n---|---\nRoute segment throws | `error.tsx` in same folder\nRoot layout throws | `global-error.tsx`\nResource not found | `notFound()` + `not-found.tsx`\nServer action fails | Return `{ ok: false, error }`\nTrack in production | Sentry in `error.tsx` + server actions\nLayout error | try/catch in layout or parent `error.tsx`\n\nProduction apps fail. The question is whether they fail gracefully with useful Sentry traces and clear user messages, or with a blank white screen and zero context to debug from.\n\n_Full guide at stacknotice.com/blog/nextjs-error-handling-2026_",
"title": "Next.js 15 Error Handling: error.tsx, Server Actions, and Sentry (2026)"
}