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.
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.