Full-Stack Engineering
Error Handling in TypeScript and Next.js: Patterns That Scale
Last updated: April 14, 2026
TL;DR
Error handling is the difference between an app that works and an app that works in production. After shipping TypeScript and Next.js apps for clients across the UK, Singapore, and Sri Lanka, I've built a set of error handling patterns that catch failures before users see them, give developers the context they need to fix issues fast, and keep UIs from crashing entirely when something goes wrong. This article covers everything: structured error types that replace string messages, consistent API error responses with proper HTTP status codes, React error boundaries, Next.js error.tsx files, the try/catch anti-patterns I see everywhere, Result types for explicit error flows, production logging, and error monitoring. These are the exact patterns running on uvin.lk and EuroParts Lanka right now.
My Error Philosophy
I have three rules about errors that I never break:
- Never swallow errors. A silent
catch {}is worse than a crash. At least a crash tells you something went wrong. A swallowed error hides the problem until it compounds into something catastrophic three months later.
- Structured error types over string messages.
throw new Error("something went wrong")is useless. Who went wrong? What went wrong? Where? A structured error with a code, message, and context gives you everything you need to debug at 2am without reading through logs line by line.
- Errors are data, not exceptions. In most of my code, I treat errors as values that flow through the system — not as exceptional interruptions. This makes error paths explicit, testable, and impossible to forget about.
These three rules shape every pattern in this article. If you take nothing else away, take these. They've saved me hundreds of hours of debugging across dozens of projects.
Structured Error Types
The first thing I build in any TypeScript project is my error type system. String errors are the enemy. They can't be pattern-matched, they can't carry context, and they make error handling code brittle.
Here's the base error class I use:
// lib/errors.ts
export class AppError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly statusCode: number = 500,
public readonly details?: Record<string, unknown>
) {
super(message);
this.name = "AppError";
}
}
export class ValidationError extends AppError {
constructor(
message: string,
public readonly fieldErrors: Record<string, string[]>
) {
super("VALIDATION_ERROR", message, 400, { fieldErrors });
this.name = "ValidationError";
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id: string) {
super(
"NOT_FOUND",
`${resource} with id ${id} not found`,
404,
{ resource, id }
);
this.name = "NotFoundError";
}
}
export class AuthenticationError extends AppError {
constructor(message = "Authentication required") {
super("UNAUTHENTICATED", message, 401);
this.name = "AuthenticationError";
}
}
export class AuthorizationError extends AppError {
constructor(message = "Insufficient permissions") {
super("FORBIDDEN", message, 403);
this.name = "AuthorizationError";
}
}
export class RateLimitError extends AppError {
constructor(
public readonly retryAfterMs: number
) {
super(
"RATE_LIMITED",
"Too many requests",
429,
{ retryAfterMs }
);
this.name = "RateLimitError";
}
}Every error has a machine-readable code, a human-readable message, an HTTP statusCode, and optional details for extra context. This means my error handling code can switch on error.code instead of parsing strings — which is both faster and impossible to typo silently.
The key insight: error classes are cheap to create and expensive to not have. The 50 lines above save me hundreds of lines of ad-hoc error handling downstream. Every new project starts with this file.
I also add a type guard to check if something is an AppError:
export function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}This becomes critical when you're catching errors at API boundaries and need to decide whether to expose the error details to the client or hide them behind a generic 500.
API Error Responses -- Consistent Format
Every API route in my Next.js apps returns errors in the same format. Always. No exceptions. A frontend developer consuming my API should never have to guess what shape an error response takes.
Here's the format:
// types/api.ts
type ApiErrorResponse = {
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
};
type ApiSuccessResponse<T> = {
data: T;
};
type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;And here's the handler that converts any error into a consistent response:
// lib/api-error-handler.ts
import { NextResponse } from "next/server";
import { AppError, isAppError } from "./errors";
export function handleApiError(error: unknown): NextResponse<ApiErrorResponse> {
// Known application errors — safe to expose
if (isAppError(error)) {
return NextResponse.json(
{
error: {
code: error.code,
message: error.message,
details: error.details,
},
},
{ status: error.statusCode }
);
}
// Zod validation errors
if (error instanceof ZodError) {
const fieldErrors = error.flatten().fieldErrors;
return NextResponse.json(
{
error: {
code: "VALIDATION_ERROR",
message: "Invalid request data",
details: { fieldErrors },
},
},
{ status: 400 }
);
}
// Unknown errors — log everything, expose nothing
console.error("Unhandled error:", error);
return NextResponse.json(
{
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
},
},
{ status: 500 }
);
}Now every route handler becomes clean:
// app/api/products/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { handleApiError } from "@/lib/api-error-handler";
import { NotFoundError, AuthenticationError } from "@/lib/errors";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const session = await getSession();
if (!session) {
throw new AuthenticationError();
}
const product = await db.product.findUnique({ where: { id } });
if (!product) {
throw new NotFoundError("Product", id);
}
return NextResponse.json({ data: product });
} catch (error) {
return handleApiError(error);
}
}Notice how the route handler reads like a story: get the session, get the product, return it. The error paths are clear. The status codes are correct. The response format is consistent. This is what I mean by errors as data — every failure case is explicit and handled.
The frontend can now handle all errors uniformly:
// lib/api-client.ts
export async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, options);
if (!response.ok) {
const body = await response.json();
const error = body.error;
throw new AppError(
error.code,
error.message,
response.status,
error.details
);
}
const body = await response.json();
return body.data as T;
}Error Boundaries in React
Server errors are one thing. UI errors are another entirely. A single unhandled exception in a React component tree brings down the entire page. Error boundaries prevent that.
Here's the error boundary I use in every project:
// components/error-boundary.tsx
"use client";
import { Component, type ReactNode } from "react";
type Props = {
children: ReactNode;
fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
};
type State = {
hasError: boolean;
error: Error | null;
};
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Send to error monitoring (Sentry, LogRocket, etc.)
reportError(error, {
componentStack: errorInfo.componentStack,
});
}
resetErrorBoundary = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError && this.state.error) {
const { fallback } = this.props;
if (typeof fallback === "function") {
return fallback(this.state.error, this.resetErrorBoundary);
}
return fallback;
}
return this.props.children;
}
}I wrap critical sections of the UI independently so one failing widget doesn't take down the whole page:
// app/dashboard/page.tsx
import { ErrorBoundary } from "@/components/error-boundary";
import { AnalyticsChart } from "@/components/analytics-chart";
import { RecentOrders } from "@/components/recent-orders";
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6">
<ErrorBoundary
fallback={(error, reset) => (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<p className="text-sm text-red-800">
Failed to load analytics: {error.message}
</p>
<button
onClick={reset}
className="mt-2 text-sm font-medium text-red-600 hover:text-red-500"
>
Try again
</button>
</div>
)}
>
<AnalyticsChart />
</ErrorBoundary>
<ErrorBoundary
fallback={<p className="text-muted">Unable to load recent orders.</p>}
>
<RecentOrders />
</ErrorBoundary>
</div>
);
}The analytics chart can crash without affecting the recent orders panel. Each section degrades gracefully. This is the pattern I follow everywhere — isolate failures to the smallest possible blast radius.
error.tsx in Next.js
Next.js gives you a file-based error handling system with error.tsx. Every route segment can have its own error file, and Next.js automatically wraps that segment in an error boundary.
Here's the error file I use at the app root:
// app/error.tsx
"use client";
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to error monitoring service
reportError(error);
}, [error]);
return (
<div className="flex min-h-[400px] flex-col items-center justify-center gap-4">
<div className="text-center">
<h2 className="text-xl font-semibold text-white">
Something went wrong
</h2>
<p className="mt-2 text-sm text-slate-400">
We've been notified and are looking into it.
</p>
{error.digest && (
<p className="mt-1 font-mono text-xs text-slate-500">
Error ID: {error.digest}
</p>
)}
</div>
<button
onClick={reset}
className="rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-600"
>
Try again
</button>
</div>
);
}For route-specific errors, I create error.tsx files in the relevant route segments. The not-found.tsx file handles 404s separately:
// app/products/[id]/not-found.tsx
export default function ProductNotFound() {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center">
<h2 className="text-xl font-semibold text-white">
Product not found
</h2>
<p className="mt-2 text-sm text-slate-400">
This product may have been removed or the link is incorrect.
</p>
<a
href="/products"
className="mt-4 text-sm font-medium text-orange-500 hover:text-orange-400"
>
Browse all products
</a>
</div>
);
}The combination of error.tsx at the root and route-specific error files gives you layered error handling. Route-specific errors catch first; the root catches everything else. No uncaught errors make it to a blank white screen.
Try/Catch Anti-Patterns
I see the same try/catch mistakes in almost every codebase I audit. Here are the patterns I refuse to ship:
The Empty Catch
// NEVER do this
try {
await saveUserPreferences(data);
} catch (error) {
// "it's fine, it's just preferences"
}This is the single worst error handling pattern in existence. The error is gone. No log, no alert, no trace. When preferences silently stop saving for 10,000 users, you'll have zero clues why. If an error truly doesn't matter, log it at a debug level. Never discard it.
The Catch-All That Hides Bugs
// NEVER do this
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const analytics = await getAnalytics(user.id);
return { user, orders, analytics };
} catch (error) {
return { user: null, orders: [], analytics: null };
}Three different operations, each with different failure modes, all hidden behind one catch that returns fake "everything is fine" data. If getUser throws because of a network error, you shouldn't return orders: [] — that's a lie. The user might have orders; you just couldn't fetch them.
What I Do Instead
Each operation gets its own error handling with specific recovery:
async function getDashboardData(userId: string) {
const user = await getUser(userId);
// If user fetch fails, let it throw — the page can't render without it
const [ordersResult, analyticsResult] = await Promise.allSettled([
getOrders(userId),
getAnalytics(userId),
]);
return {
user,
orders:
ordersResult.status === "fulfilled"
? ordersResult.value
: { data: [], error: "Failed to load orders" },
analytics:
analyticsResult.status === "fulfilled"
? analyticsResult.value
: { data: null, error: "Failed to load analytics" },
};
}Promise.allSettled is one of the most useful tools for this pattern. It runs everything in parallel and gives you the result of each operation independently. Critical data (the user) throws on failure. Non-critical data (orders, analytics) degrades gracefully with an error message the UI can display.
Result Types -- When to Use Them
For operations where errors are expected and frequent — form validation, database lookups, external API calls — I use a Result type instead of try/catch. This makes the error path explicit in the function signature.
// lib/result.ts
type Success<T> = {
success: true;
data: T;
};
type Failure<E = string> = {
success: false;
error: E;
};
type Result<T, E = string> = Success<T> | Failure<E>;
function ok<T>(data: T): Success<T> {
return { success: true, data };
}
function fail<E = string>(error: E): Failure<E> {
return { success: false, error };
}Here's how I use it for a user creation function:
type CreateUserError =
| { code: "EMAIL_EXISTS"; email: string }
| { code: "INVALID_DOMAIN"; domain: string }
| { code: "RATE_LIMITED"; retryAfterMs: number };
async function createUser(
input: CreateUserInput
): Promise<Result<User, CreateUserError>> {
const existingUser = await db.user.findUnique({
where: { email: input.email },
});
if (existingUser) {
return fail({ code: "EMAIL_EXISTS", email: input.email });
}
const domain = input.email.split("@")[1];
if (blockedDomains.includes(domain)) {
return fail({ code: "INVALID_DOMAIN", domain });
}
const user = await db.user.create({ data: input });
return ok(user);
}The caller is now forced to handle the error case:
const result = await createUser(formData);
if (!result.success) {
switch (result.error.code) {
case "EMAIL_EXISTS":
setFieldError("email", "This email is already registered");
break;
case "INVALID_DOMAIN":
setFieldError("email", "This email domain is not allowed");
break;
case "RATE_LIMITED":
showToast("Too many attempts. Please wait.");
break;
}
return;
}
// TypeScript knows result.data is User here
redirectToProfile(result.data.id);TypeScript's discriminated union narrowing makes this airtight. After checking result.success, you get full type safety on both the data and the error. No casting, no guessing.
I use Result types when:
- The function has multiple known failure modes that the caller should handle differently
- Errors are expected during normal operation (not exceptional)
- I want the compiler to enforce that callers handle errors
I use try/catch when:
- Errors are truly exceptional (network down, database crashed)
- I'm at a boundary (API route handler, top-level server action)
- The error should propagate to an error boundary
Logging Errors Properly
console.error is not a logging strategy. In production, I use structured logging that gives me the context I need to debug without drowning in noise.
// lib/logger.ts
type LogLevel = "debug" | "info" | "warn" | "error";
type LogContext = Record<string, unknown>;
function createLogger(service: string) {
function log(level: LogLevel, message: string, context?: LogContext) {
const entry = {
timestamp: new Date().toISOString(),
level,
service,
message,
...context,
};
// In production: send to logging service (Axiom, Datadog, etc.)
// In development: structured console output
if (process.env.NODE_ENV === "production") {
// Ship to your logging provider
sendToLoggingService(entry);
} else {
console[level](JSON.stringify(entry, null, 2));
}
}
return {
debug: (msg: string, ctx?: LogContext) => log("debug", msg, ctx),
info: (msg: string, ctx?: LogContext) => log("info", msg, ctx),
warn: (msg: string, ctx?: LogContext) => log("warn", msg, ctx),
error: (msg: string, ctx?: LogContext) => log("error", msg, ctx),
};
}
export const logger = createLogger("iamuvin-app");When logging errors, I always include:
try {
await processPayment(orderId, amount);
} catch (error) {
logger.error("Payment processing failed", {
orderId,
amount,
userId: session.user.id,
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
provider: "stripe",
});
throw error; // Re-throw after logging — never swallow
}The structured format means I can search my logs for all payment failures, filter by user, filter by amount range, or correlate with a specific order. Compare that to console.error("payment failed", error) — which gives you nothing actionable in a production logging dashboard.
Key rules:
- Log at the boundary, not at every level. If a function throws and the caller logs it, don't log it in both places. You'll get duplicate entries.
- Include correlation IDs. A request ID that follows the error from the API route through every service call makes tracing trivial.
- Never log sensitive data. No passwords, no tokens, no credit card numbers. Sanitize before logging.
- Re-throw after logging. Logging an error doesn't handle it. The error still needs to reach an error boundary or API error handler.
Error Monitoring in Production
Logging tells you what happened. Monitoring tells you something is happening right now. I set up error monitoring on day one of every project, not as an afterthought.
My stack:
- Sentry for error tracking — automatic source maps, breadcrumbs, user context
- Axiom for structured logs — query language for searching through millions of log entries
- Vercel Analytics for performance errors — Core Web Vitals degradation alerts
Here's my Sentry setup for Next.js:
// sentry.client.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.1, // 10% of transactions in production
replaysOnErrorSampleRate: 1.0, // 100% replay on errors
replaysSessionSampleRate: 0.01, // 1% of sessions
beforeSend(event) {
// Strip PII before sending
if (event.user) {
delete event.user.ip_address;
delete event.user.email;
}
return event;
},
ignoreErrors: [
// Browser extensions and network noise
"ResizeObserver loop",
"Non-Error promise rejection",
/^Loading chunk \d+ failed/,
],
});The ignoreErrors list is important. Without it, your Sentry inbox fills with browser extension noise and you stop paying attention to real errors. I curate this list carefully — only errors that are genuinely outside my control and not actionable.
For the reportError function I referenced in earlier examples:
// lib/error-reporting.ts
import * as Sentry from "@sentry/nextjs";
export function reportError(
error: Error,
context?: Record<string, unknown>
) {
Sentry.withScope((scope) => {
if (context) {
Object.entries(context).forEach(([key, value]) => {
scope.setExtra(key, value);
});
}
Sentry.captureException(error);
});
}I set up Sentry alerts for:
- New errors — any error type I haven't seen before
- Error rate spikes — more than 10 errors per minute (adjusted per project traffic)
- Specific error codes —
UNAUTHENTICATEDspikes could indicate an auth system issue or an attack
My Error Handling Checklist
Before shipping any feature, I run through this checklist:
- [ ] Every
catchblock either logs and re-throws, or handles the error explicitly with user-facing feedback - [ ] No empty
catchblocks anywhere in the codebase - [ ] All API routes return the consistent
{ error: { code, message, details } }format - [ ] HTTP status codes are correct: 400 for bad input, 401 for unauthenticated, 403 for unauthorized, 404 for missing resources, 429 for rate limits, 500 for server errors
- [ ] Validation errors include field-level detail so the UI can highlight the right input
- [ ] Critical UI sections are wrapped in error boundaries with meaningful fallback UI
- [ ]
error.tsxexists at the app root and in route segments that need custom error UIs - [ ]
not-found.tsxexists for dynamic routes where resources might not exist - [ ] Sensitive data (passwords, tokens, PII) is never included in error messages or logs
- [ ] Error monitoring (Sentry or equivalent) is configured with appropriate alert thresholds
- [ ] Result types are used for functions with multiple expected failure modes
- [ ]
Promise.allSettledis used for parallel operations where partial failure is acceptable - [ ] Every error includes enough context (IDs, timestamps, operation name) to debug without reproducing
This checklist lives in my project templates. It's not optional. Skipping even one of these items has bitten me in production before — and I learn from every bite.
Key Takeaways
- Build your error type system first. The
AppErrorclass hierarchy with codes, messages, and status codes is the foundation everything else builds on.
- Consistent API error format is non-negotiable. Frontend developers should never guess what shape an error response takes.
{ error: { code, message, details } }— always.
- Error boundaries are cheap insurance. Wrap independent UI sections so one failing component doesn't crash the whole page. The five minutes to set up an error boundary saves hours of user complaints.
- Result types make error handling explicit. For functions with known failure modes, return
Result<T, E>instead of throwing. The compiler becomes your safety net.
- Never swallow errors. Every catch block should log, re-throw, or handle with user feedback. An empty catch is a ticking time bomb.
- Monitor from day one. Set up Sentry before you write your first feature. The bugs you catch in the first week are always the ones that would have been hardest to find later.
These patterns aren't theoretical. They're running in production right now on apps serving real users. Every one of them was earned by debugging a production incident that shouldn't have happened.
*I build production-grade web applications with TypeScript, Next.js, and modern tooling. If you need a developer who treats error handling as a first-class concern — not an afterthought — check out my services.*
*Uvin Vindula (@iamuvin↗) -- Full-stack engineer, Web3 builder, and AI product developer based in Sri Lanka and the UK. Building at uvin.lk↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.