← Writing16 min read
Deep Dive

Error Handling in Next.js + tRPC + React Query + Prisma: A Complete Guide

A thorough walkthrough of every error boundary in a modern full-stack TypeScript application — tRPC errors, React Query error states, Next.js built-in error files, and react-error-boundary for granular client-side recovery.

OOmar Harkouss
7 March 202616 min read

Error handling is the part of application development that separates engineers who ship reliable software from engineers who ship software that sometimes works. It is also the part most commonly treated as an afterthought — bolted on after the happy path is complete, and usually incomplete.

In a Next.js + tRPC + React Query + Prisma stack, there are four distinct error handling layers. Each layer has a different responsibility, a different failure mode it is designed to catch, and a different API for expressing recovery behavior. Understanding all four — and when to use which — is the difference between an application that degrades gracefully under failure and one that presents blank screens or confusing states to users.

This article examines each layer in full, with working patterns, code examples, and an honest account of what each layer can and cannot do.


The Mental Model: Four Layers, One Request

Before examining each layer individually, it helps to see them as a stack through which a single user action passes:

User action (button click, page load, form submit)
    │
    ▼
[Layer 4] Next.js Error Boundaries (error.tsx / global-error.tsx)
    │      Catches: rendering errors, unhandled promise rejections at page level
    ▼
[Layer 3] React Error Boundary (react-error-boundary)
    │      Catches: rendering errors in specific component subtrees
    ▼
[Layer 2] React Query Error State (onError, isError, error)
    │      Catches: network failures, non-2xx responses, tRPC errors surfaced as exceptions
    ▼
[Layer 1] tRPC Error Handling (TRPCError, error formatter, onError)
           Catches: validation failures, authorization violations, business logic errors

Data errors originate at Layer 1 and propagate upward. Rendering errors originate at Layers 3 and 4 independently. Understanding where an error originates determines which layer should handle it — and understanding the propagation path determines whether you need multiple layers working in concert.


I. tRPC Error Handling: Where Errors Are Born

tRPC is the boundary between your application logic and the outside world. It is where you receive untrusted input, enforce authorization, execute database queries, and call external APIs. Every category of error that can occur in a backend system passes through this layer.

The TRPCError Type

tRPC's primary error primitive is TRPCError, a typed exception that carries two mandatory fields: a code that maps to an HTTP status, and a message.

import { TRPCError } from "@trpc/server";

// Authorization failure
throw new TRPCError({
  code: "FORBIDDEN",
  message: "You do not have permission to access this resource.",
});

// Resource not found
throw new TRPCError({
  code: "NOT_FOUND",
  message: "Job posting not found or has been removed.",
});

// Validation that Zod can't express
throw new TRPCError({
  code: "BAD_REQUEST",
  message: "Application deadline has already passed.",
});

// Upstream failure
throw new TRPCError({
  code: "INTERNAL_SERVER_ERROR",
  message: "CV analysis service temporarily unavailable.",
  cause: originalError,  // preserve the original for logging
});

The available codes map to their HTTP equivalents: BAD_REQUEST (400), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), INTERNAL_SERVER_ERROR (500), and others. Choose the code that accurately describes the failure semantics — the client will use this to determine how to respond.

Zod Input Validation: Free Error Handling

Every tRPC procedure that uses .input(schema) gets input validation error handling for free. If the client sends a payload that does not conform to the Zod schema, tRPC automatically throws a BAD_REQUEST error before your procedure handler runs. You do not need to write validation error handling manually.

const createJobProcedure = recruiterProcedure
  .input(z.object({
    title: z.string().min(5).max(100),
    salary: z.number().positive(),
    location: z.string(),
  }))
  .mutation(async ({ input, ctx }) => {
    // input is fully typed and validated before this line executes
    // a malformed payload never reaches your handler
    return db.job.create({ data: { ...input, recruiterId: ctx.user.id } });
  });

This is one of tRPC's most underappreciated features. In a REST API, you write validation logic explicitly and handle its errors manually. In tRPC, the schema is the validation, and tRPC handles the errors.

The Error Formatter: Shaping What the Client Sees

By default, tRPC sends a subset of error information to the client. Production systems typically need to enrich this — adding a request ID for support correlation, sanitizing internal details in production, or attaching field-level validation errors for forms.

The errorFormatter option on your tRPC router configuration is the correct place for this:

// server/trpc.ts
import { initTRPC } from "@trpc/server";
import { ZodError } from "zod";

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        // Attach Zod field errors when present — used by forms
        zodError:
          error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
        // Attach request ID for support correlation
        requestId: shape.data.requestId ?? generateRequestId(),
      },
    };
  },
});

With this formatter, a validation failure from a form submission returns structured field errors that the client can map directly to input fields — without any parsing of the error message string.

The onError Middleware: Observability

Error handling in a procedure is for the client. The onError callback in your tRPC handler configuration is for you — the engineer operating the system.

// app/api/trpc/[trpc]/route.ts
export const { GET, POST } = createNextRouteHandler({
  router: appRouter,
  createContext,
  onError({ error, type, path, input, ctx, req }) {
    // Always log 5xx errors — these are your bugs
    if (error.code === "INTERNAL_SERVER_ERROR") {
      logger.error("tRPC internal error", {
        path,
        type,
        error: error.message,
        cause: error.cause,
        userId: ctx?.user?.id,
      });
    }

    // Log 4xx only in development — in production they are expected
    if (process.env.NODE_ENV === "development") {
      console.error(`tRPC ${type} error on ${path}:`, error);
    }
  },
});

The distinction between logging 5xx and 4xx errors is important. A FORBIDDEN or NOT_FOUND error is not your bug — it is an expected failure that your authorization logic correctly detected. Logging every 4xx creates noise that buries the signal of real errors. Log INTERNAL_SERVER_ERROR always. Log the rest selectively.

Wrapping Prisma Errors

Prisma throws its own error types — PrismaClientKnownRequestError, PrismaClientUnknownRequestError — that tRPC does not understand. Letting Prisma errors propagate unhandled will result in tRPC sending an INTERNAL_SERVER_ERROR to the client for cases that should be NOT_FOUND or CONFLICT.

A utility function to normalize this keeps your procedure handlers clean:

// lib/prisma-error.ts
import { Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";

export function handlePrismaError(error: unknown): never {
  if (error instanceof Prisma.PrismaClientKnownRequestError) {
    switch (error.code) {
      case "P2025": // Record not found
        throw new TRPCError({ code: "NOT_FOUND", message: "Record not found.", cause: error });
      case "P2002": // Unique constraint violation
        throw new TRPCError({ code: "CONFLICT", message: "This record already exists.", cause: error });
      case "P2003": // Foreign key constraint failed
        throw new TRPCError({ code: "BAD_REQUEST", message: "Referenced resource does not exist.", cause: error });
    }
  }
  throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Database error.", cause: error });
}

// Usage in a procedure
try {
  await db.application.create({ data: input });
} catch (error) {
  handlePrismaError(error);
}

II. React Query Error Handling: The Client-Side Contract

React Query is the client-side runtime for your tRPC procedures. When a tRPC procedure throws, React Query catches the rejection and exposes it through its standard error state interface. Your UI layer interacts with errors through React Query — not directly through the tRPC client.

The Error Object Shape

tRPC's React Query integration gives you a typed error object. The TRPCClientError type carries the original error shape, including the code, message, and any custom data added by your errorFormatter.

import { type TRPCClientError } from "@trpc/client";
import { type AppRouter } from "~/server/root";

// Full type of a tRPC error on the client
type AppError = TRPCClientError<AppRouter>;

A helper to extract human-readable messages from this error type:

function getErrorMessage(error: unknown): string {
  if (error instanceof TRPCClientError) {
    // Return the message from the procedure — this is already user-facing
    return error.message;
  }
  // Network failure — the request never reached the server
  if (error instanceof Error) {
    return "Connection failed. Check your internet connection.";
  }
  return "An unexpected error occurred.";
}

Query Error States

For data fetching — tRPC queries — React Query exposes isError and error alongside the standard loading states:

function JobListings() {
  const { data, isLoading, isError, error } = api.jobs.list.useQuery({
    status: "ACTIVE",
    limit: 20,
  });

  if (isLoading) return <JobListingsSkeleton />;

  if (isError) {
    // error is typed as TRPCClientError<AppRouter>
    const isNotFound = error.data?.code === "NOT_FOUND";
    const isForbidden = error.data?.code === "FORBIDDEN";

    if (isForbidden) return <AccessDenied />;
    if (isNotFound) return <EmptyState message="No job listings found." />;

    return (
      <ErrorState
        message={error.message}
        onRetry={() => refetch()}
      />
    );
  }

  return <JobGrid jobs={data.jobs} />;
}

The branching on error.data?.code is the correct pattern. Different error codes should produce different UI responses — a FORBIDDEN should never show a generic "something went wrong" message when you can show an access denied state instead.

Mutation Error Handling

Mutations have a different error surface than queries. Where a query error is a persistent state, a mutation error is an event — it fires once, and the component needs to respond to it at that moment.

There are two patterns for mutation error handling, and they serve different use cases:

Pattern 1: Inline callbacks — best for fire-and-forget actions

function ApplicationActions({ applicationId }: { applicationId: string }) {
  const updateStatus = api.recruiter.applications.updateStatus.useMutation({
    onSuccess: () => {
      toast.success("Application status updated.");
    },
    onError: (error) => {
      // error.data?.code is available here
      toast.error(error.message);
    },
  });

  return (
    <button onClick={() => updateStatus.mutate({ applicationId, status: "SHORTLISTED" })}>
      Shortlist
    </button>
  );
}

Pattern 2: Checking mutation state — best for form submission with field errors

function ApplyToJobForm({ jobId }: { jobId: string }) {
  const apply = api.candidate.applications.create.useMutation();

  const handleSubmit = async (formData: ApplicationFormData) => {
    try {
      await apply.mutateAsync(formData);
      router.push("/candidate/applications");
    } catch (error) {
      // mutateAsync re-throws, so you can catch here for programmatic control
      // The error is still available on apply.error for rendering
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* ... form fields ... */}

      {apply.isError && (
        <FormError>
          {apply.error?.data?.zodError
            ? "Please fix the validation errors above."
            : apply.error?.message}
        </FormError>
      )}

      {/* Map Zod field errors to individual inputs */}
      {apply.error?.data?.zodError?.fieldErrors.coverLetter && (
        <FieldError>{apply.error.data.zodError.fieldErrors.coverLetter[0]}</FieldError>
      )}

      <button type="submit" disabled={apply.isPending}>
        {apply.isPending ? "Submitting..." : "Apply"}
      </button>
    </form>
  );
}

mutateAsync vs mutate is a consequential choice. mutate swallows the promise rejection — errors are only surfaced through the mutation state object. mutateAsync re-throws, giving you try/catch control flow. Use mutateAsync when you need to perform actions after a successful mutation (navigation, state updates). Use mutate when the onSuccess/onError callbacks are sufficient.

Global Error Behavior with the QueryClient

For certain categories of errors — 401 Unauthorized responses, which indicate an expired session — the correct behavior is global: redirect to login regardless of which query triggered the error. Configuring this in the QueryClient avoids scattering the same redirect logic across every component.

// lib/query-client.ts
import { QueryClient } from "@tanstack/react-query";
import { TRPCClientError } from "@trpc/client";

export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // Don't retry on auth errors or client errors — they won't resolve themselves
        retry: (failureCount, error) => {
          if (error instanceof TRPCClientError) {
            const code = error.data?.code;
            if (code === "UNAUTHORIZED" || code === "FORBIDDEN" || code === "NOT_FOUND") {
              return false;
            }
          }
          return failureCount < 2;
        },
        // Stale time: avoid re-fetching reference data on every mount
        staleTime: 30 * 1000,
      },
      mutations: {
        onError: (error) => {
          if (error instanceof TRPCClientError && error.data?.code === "UNAUTHORIZED") {
            // Session expired — redirect globally
            window.location.href = "/login";
          }
        },
      },
    },
  });
}

The retry configuration is often overlooked. The default behavior retries failed queries three times — reasonable for transient network failures, harmful for authorization errors. A query that returns FORBIDDEN will never succeed on a retry; retrying it wastes requests and delays showing the user a useful error state.


III. Next.js Built-In Error Boundaries: Page-Level Recovery

Next.js 13+ App Router provides a file-based error boundary system. Placing an error.tsx file in a route segment makes that segment the error boundary for all rendering errors and unhandled promise rejections within that segment and its children.

The Anatomy of error.tsx

// app/candidate/error.tsx
"use client"; // Error boundaries must be client components

import { useEffect } from "react";

interface ErrorPageProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function CandidateError({ error, reset }: ErrorPageProps) {
  useEffect(() => {
    // Log to your error reporting service (Sentry, Axiom, etc.)
    logger.error("Candidate section rendering error", {
      message: error.message,
      digest: error.digest, // Next.js server-side error ID for log correlation
      stack: error.stack,
    });
  }, [error]);

  return (
    <div className="error-container">
      <h2>Something went wrong in the candidate dashboard.</h2>
      <p>{error.message}</p>
      {error.digest && (
        <p className="error-id">Error ID: {error.digest}</p>
      )}
      <button onClick={reset}>Try again</button>
    </div>
  );
}

The reset function re-renders the error boundary's subtree by attempting to re-render the children. If the original error was transient — a rendering edge case triggered by a specific data state — reset can recover without a page reload. If the error is deterministic, reset will trigger again immediately.

The error.digest is a server-assigned ID that links the client-facing error to the server-side log entry. Displaying it and logging it together is what makes production incident investigation tractable: a user can report the ID, and you can find the exact server log entry immediately.

Error Boundary Hierarchy

A common mistake is placing a single error.tsx at the top of the route tree and calling it done. This recovers from errors, but at the cost of granularity — a single data-fetching failure in a sidebar widget unmounts the entire page.

The correct approach mirrors the recovery granularity you want. Use multiple error.tsx files at appropriate levels:

app/
  error.tsx              ← catches errors in the root layout
  candidate/
    error.tsx            ← catches errors across the candidate section
    dashboard/
      error.tsx          ← catches dashboard-specific errors (isolated recovery)
    applications/
      error.tsx          ← catches application section errors

With this structure, an error in the applications page shows the applications error UI without unmounting the candidate dashboard navigation. The recovery scope matches the failure scope.

global-error.tsx: The Last Resort

global-error.tsx is distinct from error.tsx. It catches errors that occur in the root layout itself — the <html> and <body> tags. Because it replaces the entire document, it must provide its own <html> and <head>:

// app/global-error.tsx
"use client";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <html>
      <head>
        <title>Application Error</title>
      </head>
      <body>
        <div style={{ padding: "2rem", fontFamily: "sans-serif" }}>
          <h1>Application Error</h1>
          <p>A critical error has occurred. Our team has been notified.</p>
          {error.digest && <code>Reference: {error.digest}</code>}
          <br />
          <button onClick={reset}>Reload</button>
        </div>
      </body>
    </html>
  );
}

This file should be minimal. If you reach global-error.tsx, something is fundamentally wrong with the application's root structure — that is not the moment for elaborate UI.

not-found.tsx and Other Semantic Error Files

Next.js also provides not-found.tsx for 404 errors and the ability to throw notFound() from Server Components. This is semantically superior to rendering a generic error:

// app/jobs/[id]/page.tsx (Server Component)
import { notFound } from "next/navigation";

export default async function JobPage({ params }: { params: { id: string } }) {
  const job = await db.job.findUnique({ where: { id: params.id } });

  if (!job || job.status !== "ACTIVE") {
    notFound(); // renders app/jobs/not-found.tsx or app/not-found.tsx
  }

  return <JobDetail job={job} />;
}

Use notFound() for missing resources, redirect() for auth redirects, and error.tsx for unexpected failures. Each has distinct semantics that Next.js communicates correctly to both users and search engines.


IV. react-error-boundary: Surgical Recovery for Client Components

Next.js error.tsx boundaries operate at the route segment level. For Client Component trees that need more granular error isolation — a comments widget, a recommendation sidebar, a real-time notification feed — the react-error-boundary library provides component-level control.

Installation and Basic Usage

npm install react-error-boundary
import { ErrorBoundary } from "react-error-boundary";

function CandidateDashboard() {
  return (
    <div className="dashboard-layout">
      {/* If this fails, only this section shows an error */}
      <ErrorBoundary fallback={<RecommendedJobsError />}>
        <RecommendedJobs />
      </ErrorBoundary>

      {/* The rest of the dashboard remains functional */}
      <ApplicationHistory />
      <ProfileCompletion />
    </div>
  );
}

This is the primary value proposition of react-error-boundary: a failure in RecommendedJobs does not unmount ApplicationHistory or ProfileCompletion. The dashboard degrades partially, not completely.

The FallbackComponent Pattern

For recovery UI that needs to interact — a retry button, an error report link — use the FallbackComponent prop instead of fallback:

import { ErrorBoundary, type FallbackProps } from "react-error-boundary";

function WidgetErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div className="widget-error">
      <p>This section failed to load.</p>
      <p className="error-detail">{error.message}</p>
      <button onClick={resetErrorBoundary}>Retry</button>
    </div>
  );
}

function MessagingWidget() {
  return (
    <ErrorBoundary
      FallbackComponent={WidgetErrorFallback}
      onError={(error, info) => {
        // info.componentStack is the React component stack at the time of error
        logger.error("MessagingWidget render error", { error, componentStack: info.componentStack });
      }}
    >
      <ConversationList />
    </ErrorBoundary>
  );
}

The onError prop fires synchronously when the boundary catches an error — before the fallback renders. This is the correct place to report rendering errors to your monitoring service with the component stack trace.

The resetKeys Pattern: Query-Aware Recovery

The most powerful feature of react-error-boundary in a React Query context is the resetKeys prop. It automatically resets the error boundary when specified values change — enabling recovery after a successful re-fetch.

import { ErrorBoundary } from "react-error-boundary";
import { useQueryErrorResetBoundary } from "@tanstack/react-query";

function ShortlistedCandidatesSection({ recruiterId }: { recruiterId: string }) {
  const { reset } = useQueryErrorResetBoundary();

  return (
    <ErrorBoundary
      onReset={reset} // cancels in-flight queries when boundary resets
      resetKeys={[recruiterId]} // reset automatically if recruiterId changes
      FallbackComponent={({ resetErrorBoundary }) => (
        <div>
          <p>Failed to load shortlisted candidates.</p>
          <button onClick={resetErrorBoundary}>Reload</button>
        </div>
      )}
    >
      <ShortlistedCandidatesList />
    </ErrorBoundary>
  );
}

useQueryErrorResetBoundary from React Query is the bridge between the two systems. When the error boundary resets, it also resets the query error state — ensuring React Query re-fetches rather than immediately re-throwing the cached error.

Suspense + ErrorBoundary: The Full Composition

For Server Component data that needs both loading and error handling at the component level, Suspense and ErrorBoundary compose naturally:

import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";

function RecruiterDashboard() {
  return (
    <div>
      {/* Each section loads and fails independently */}
      <ErrorBoundary fallback={<SectionError label="Pending Applications" />}>
        <Suspense fallback={<ApplicationsSkeleton />}>
          <PendingApplicationsPanel />
        </Suspense>
      </ErrorBoundary>

      <ErrorBoundary fallback={<SectionError label="Job Metrics" />}>
        <Suspense fallback={<MetricsSkeleton />}>
          <JobMetricsPanel />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

The nesting order matters: ErrorBoundary wraps Suspense wraps the data-fetching component. This ensures that if the async data fetch throws — rather than suspends — the error boundary catches it rather than propagating up.


V. Putting It Together: A Complete Error Handling Strategy

Each layer handles a different category of error. The strategy for a production application is not to choose one — it is to deploy all four at the appropriate level.

The Decision Matrix

| Error Category | Correct Layer | |---|---| | Input validation (Zod) | tRPC — automatically handled | | Authorization failure | tRPC TRPCError + React Query isError | | Business logic violation | tRPC TRPCError + React Query isError | | Database unique constraint | tRPC Prisma error wrapper | | External API failure | tRPC TRPCError with INTERNAL_SERVER_ERROR | | Network failure (offline) | React Query retry + isError | | Session expiry (401) | React Query global onError → redirect | | Server Component rendering error | Next.js error.tsx | | Client Component subtree crash | react-error-boundary | | Root layout failure | global-error.tsx | | Expected 404 | notFound()not-found.tsx |

Where to Draw the Boundary

A question that comes up in every real codebase: should the component handle the error via isError, or should it throw and let the nearest error boundary catch it?

The answer comes down to recovery semantics. If the user can take an action to recover — retry, fix input, navigate away — handle it inline with isError. If the component cannot render at all without the data, and there is no meaningful recovery action available within the component, throw and let the boundary handle it.

// Inline handling — user can retry or navigate
function ApplicationStatus({ applicationId }: { applicationId: string }) {
  const { data, isError, error, refetch } = api.candidate.applications.getOne.useQuery({ applicationId });

  if (isError) {
    return (
      <div>
        <p>Could not load application status.</p>
        <button onClick={() => refetch()}>Retry</button>
      </div>
    );
  }

  return <StatusBadge status={data?.status} />;
}

// Throw — no meaningful in-component recovery possible
function CriticalProfileData() {
  const { data } = api.candidate.profile.get.useQuery(undefined, {
    suspense: true, // throws while loading, throws on error
  });

  // This line only runs if data is available
  return <ProfileView profile={data} />;
}
// Wrap CriticalProfileData in ErrorBoundary + Suspense at the parent level

Error Messages That Help Users

The final layer of error handling is the message itself. An error message is not a debugging artifact — it is a communication to a user who is confused or frustrated. The discipline here is to distinguish between what happened (which helps you debug), what it means to the user, and what they can do next.

// Anti-pattern: leaking implementation details
throw new TRPCError({
  code: "CONFLICT",
  message: "Unique constraint failed on field: application_candidateId_jobId_key",
});

// Correct: user-facing message
throw new TRPCError({
  code: "CONFLICT",
  message: "You have already applied to this position.",
});

On the server, log the technical details. In the message field, write what you would tell the user to their face.


Conclusion

Error handling in a Next.js + tRPC + React Query + Prisma stack is not a single concern — it is a system of four cooperating layers. tRPC produces typed, semantically correct errors at the server boundary. React Query surfaces them as first-class state in the component tree. Next.js error.tsx files provide page-level recovery for rendering failures. react-error-boundary provides surgical isolation for client component subtrees.

The engineers who treat these four layers as complementary — each responsible for a distinct failure category — build applications that fail predictably, recover gracefully, and provide users with meaningful feedback when things go wrong. The engineers who treat error handling as an afterthought build applications that present blank screens and console errors.

Your error handling is not an edge case. It is the contract your application makes with every user who encounters a problem.

← All articlesGet in touch →