{
  "$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)"
}