Advanced Error Handling in TypeScript + Next.js: Stop Crying in Production
Advanced Error Handling in TypeScript + Next.js: Stop Crying in Production
You know the feeling. It's 2 AM. Your phone buzzes. Production is down. You open the codebase, and somewhere inside a 400-line server action, there's a try/catch block that swallows errors like a black hole. No useful message. No type safety. Just catch (e: any) and a prayer.
You patch it. Ship it. Go back to sleep. And then two weeks later, a different server action breaks with the exact same class of error, because your error handling strategy was "copy this try/catch from the other file and hope for the best."
Sound familiar? Good. Because today, we're going to fix this forever.
We'll use a createProject feature as our running example, and by the end, you'll have a battle-tested error handling architecture that works in server actions, API routes, UI components, background jobs, and literally anything else you throw at it.
The Problem: Why Error Handling Becomes a Dumpster Fire
Here's what happens to most codebases as they grow:
- Copy-paste try/catch blocks everywhere with slightly different messages.
- Framework-specific code (like
redirect()orNextResponse.json()) leaking into business logic. - No compiler help when you add a new error type — you have to manually hunt down every
catchblock. - Silent failures because someone caught an error and forgot to re-throw or return it.
The root cause? Error handling is scattered across the codebase instead of centralized. It's like having 15 different security guards, each with their own rulebook, guarding the same building.
What you'll learn: Why "just add a try/catch" doesn't scale past a few files.
Why this section matters: Understanding the disease helps you appreciate the cure.
The Junior Pattern: Inline Everything (a.k.a. "The Spaghetti")
Let's look at what most of us write when we're starting out. A server action that handles project creation with everything jammed inline:
// app/actions/createProject.ts — The Junior Way
"use server";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { getSession } from "@/lib/auth";
export async function createProjectAction(formData: FormData) {
const name = formData.get("name") as string;
// Validation — inline
if (!name || name.length < 3) {
return { error: "Project name must be at least 3 characters." };
}
// Auth check — inline
const session = await getSession();
if (!session) {
redirect("/login");
}
// Permission check — inline
if (session.user.plan === "free" && session.user.projectCount >= 3) {
return { error: "Free plan allows a maximum of 3 projects. Please upgrade." };
}
try {
// Conflict check — inline
const existing = await db.project.findFirst({ where: { name } });
if (existing) {
return { error: "A project with this name already exists." };
}
const project = await db.project.create({
data: { name, ownerId: session.user.id },
});
return { success: true, projectId: project.id };
} catch (e) {
// The classic "catch everything and cry"
console.error(e);
return { error: "Something went wrong. Please try again." };
}
}
Why does this suck?
On the surface, this looks fine. It works, right? But here's the problem: imagine you also need to create projects from:
- An API route (returning HTTP 409, 401, 403 status codes)
- A background job (logging errors, sending alerts — no user-facing messages)
- A CLI tool (printing to terminal)
- A webhook handler (returning structured JSON)
You'd have to copy-paste this entire block of logic into each entry point. And when the business rules change (e.g., "free plan now allows 5 projects"), you update one file and forget the other three. Congratulations, you now have a distributed bug.
Analogy: This is like writing the same recipe in four different cookbooks. When you discover the oven temperature is wrong, you fix one cookbook and poison three dinner parties.
What you'll learn: The specific pain points of inline error handling.
Why this section matters: If you've been writing code like this (we all have!), recognizing the pattern is step one to breaking it.
The Senior Pattern: Service Layer + Centralized Error Modeling
Senior engineers separate what happens (business logic) from what to do about it (framework-specific responses). The key insight is:
Your service layer should describe errors. Your entry points should react to them.
Step 1: Define Your Error Codes
First, we create a single source of truth for every error your feature can produce:
// lib/errors.ts
export const ErrorCode = {
VALIDATION: "VALIDATION",
UNAUTHENTICATED: "UNAUTHENTICATED",
FORBIDDEN: "FORBIDDEN",
CONFLICT: "CONFLICT",
INTERNAL: "INTERNAL",
} as const;
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
This is the "menu" of everything that can go wrong. It's a union type, so TypeScript knows exactly what values are valid. No more guessing, no more string typos.
Step 2: Build the Result Type (Discriminated Union)
Instead of throwing errors, we return them. This is the game changer.
// lib/result.ts
import { type ErrorCode } from "./errors";
// The success side
type Success<T> = {
ok: true;
data: T;
};
// The failure side
type Failure = {
ok: false;
error: {
code: ErrorCode;
message: string; // Human-friendly message
fieldErrors?: Record<string, string>; // For form validation
};
};
// The Result type — it's ALWAYS one or the other, never both.
export type Result<T> = Success<T> | Failure;
// Helper constructors (because typing { ok: true, ... } everywhere is annoying)
export function ok<T>(data: T): Result<T> {
return { ok: true, data };
}
export function fail(
code: ErrorCode,
message: string,
fieldErrors?: Record<string, string>
): Result<never> {
return { ok: false, error: { code, message, fieldErrors } };
}
Why return instead of throw?
throwis invisible to the type system. TypeScript doesn't track what a function throws, so you get zero compiler help when errors change.- A
Resultreturn type makes errors part of the function's contract. If the function says it returnsResult<Project>, the caller is forced to handle bothok: trueandok: false.
Analogy: throw is like mailing a letter — you hope it arrives. Result is a face-to-face handoff — you know it was received.
Step 3: Write the Service (Framework-Free)
Now the beautiful part. Our service has zero knowledge of Next.js, HTTP, or UI:
// services/project-service.ts
import { type Result, ok, fail } from "@/lib/result";
import { ErrorCode } from "@/lib/errors";
import { db } from "@/lib/db";
// Input type
interface CreateProjectInput {
name: string;
userId: string | null; // null = not logged in
userPlan: "free" | "pro";
currentProjectCount: number;
}
// Output type
interface Project {
id: string;
name: string;
}
export async function createProject(
input: CreateProjectInput
): Promise<Result<Project>> {
// 1. Validation
if (!input.name || input.name.trim().length < 3) {
return fail(ErrorCode.VALIDATION, "Project name must be at least 3 characters.", {
name: "Name is too short (minimum 3 characters).",
});
}
// 2. Authentication
if (!input.userId) {
return fail(ErrorCode.UNAUTHENTICATED, "You must be logged in to create a project.");
}
// 3. Authorization
if (input.userPlan === "free" && input.currentProjectCount >= 3) {
return fail(ErrorCode.FORBIDDEN, "Free plan allows a maximum of 3 projects. Please upgrade.");
}
// 4. Conflict check
const existing = await db.project.findFirst({
where: { name: input.name.trim() },
});
if (existing) {
return fail(ErrorCode.CONFLICT, `A project named "${input.name}" already exists.`);
}
// 5. Create (wrap infra errors)
try {
const project = await db.project.create({
data: { name: input.name.trim(), ownerId: input.userId },
});
return ok({ id: project.id, name: project.name });
} catch (e) {
console.error("Failed to create project:", e);
return fail(ErrorCode.INTERNAL, "Something went wrong. Please try again later.");
}
}
Notice what's not in this file:
- No
redirect(). - No
NextResponse. - No HTTP status codes.
- No toast notifications.
It's pure business logic. It just says what happened and lets the caller decide what to do about it. That is the power of separation.
What you'll learn: How to build a framework-agnostic service layer with typed results.
Why this section matters: This is the single biggest architectural improvement you can make for error handling. Everything else builds on this.
Exhaustive Handling with assertNever
Here's where TypeScript becomes your personal bodyguard. We add a tiny utility:
// lib/assert-never.ts
export function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
What does this do? It forces you to handle every single error code in a switch statement. If you add a new error code (say, "RATE_LIMITED") to the ErrorCode union, every switch that doesn't handle it will show a compiler error. Immediately. No hunting.
import { assertNever } from "@/lib/assert-never";
function handleError(code: ErrorCode): string {
switch (code) {
case "VALIDATION":
return "Please fix the highlighted fields.";
case "UNAUTHENTICATED":
return "Please log in to continue.";
case "FORBIDDEN":
return "You don't have permission. Contact your admin.";
case "CONFLICT":
return "This resource already exists.";
case "INTERNAL":
return "Something went wrong on our end.";
default:
// If you forget a case, TypeScript screams here.
// "Argument of type 'RATE_LIMITED' is not assignable to parameter of type 'never'."
return assertNever(code);
}
}
Now adding a new error type is like adding a checkbox to a to-do list — TypeScript makes sure you check every box.
What you'll learn: How
assertNeverenforces exhaustive handling at compile time.Why this section matters: This is the difference between "I hope I handled everything" and "the compiler guarantees I handled everything."
Consuming the Service: Three Entry Points, One Source of Truth
Here's the payoff. We consume the same createProject service from three different entry points, and each one handles errors its own way — without duplicating a single line of business logic.
1. Next.js Server Action (Form Handling)
// app/actions/createProject.ts
"use server";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { createProject } from "@/services/project-service";
import { assertNever } from "@/lib/assert-never";
export async function createProjectAction(formData: FormData) {
const session = await getSession();
const result = await createProject({
name: formData.get("name") as string,
userId: session?.user.id ?? null,
userPlan: session?.user.plan ?? "free",
currentProjectCount: session?.user.projectCount ?? 0,
});
if (result.ok) {
redirect(`/projects/${result.data.id}`);
}
// Handle each error appropriately for the server action context
const { code } = result.error;
switch (code) {
case "VALIDATION":
// Return field errors to the form
return { fieldErrors: result.error.fieldErrors, message: result.error.message };
case "UNAUTHENTICATED":
redirect("/login");
case "FORBIDDEN":
case "CONFLICT":
case "INTERNAL":
return { message: result.error.message };
default:
return assertNever(code);
}
}
Clean: ~25 lines instead of the original ~40. And the logic isn't here — it's in the service.
2. Next.js API Route (HTTP Status Mapping)
// app/api/projects/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
import { createProject } from "@/services/project-service";
import { type ErrorCode } from "@/lib/errors";
import { assertNever } from "@/lib/assert-never";
// Map error codes to HTTP status codes — defined ONCE
const httpStatusMap: Record<ErrorCode, number> = {
VALIDATION: 400,
UNAUTHENTICATED: 401,
FORBIDDEN: 403,
CONFLICT: 409,
INTERNAL: 500,
};
export async function POST(req: NextRequest) {
const session = await getSession();
const body = await req.json();
const result = await createProject({
name: body.name,
userId: session?.user.id ?? null,
userPlan: session?.user.plan ?? "free",
currentProjectCount: session?.user.projectCount ?? 0,
});
if (result.ok) {
return NextResponse.json(result.data, { status: 201 });
}
return NextResponse.json(
{ error: result.error.message, code: result.error.code },
{ status: httpStatusMap[result.error.code] }
);
}
That httpStatusMap object is the entire "translation layer" between your business errors and HTTP. Add a new error code? TypeScript yells because the Record<ErrorCode, number> is incomplete. You literally cannot forget.
3. UI-Level Handling (Forms, Toasts, Redirects)
// components/CreateProjectForm.tsx
"use client";
import { useActionState } from "react";
import { createProjectAction } from "@/app/actions/createProject";
import { toast } from "sonner";
export function CreateProjectForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: any, formData: FormData) => {
const result = await createProjectAction(formData);
// If we got redirected, this code won't run (redirect throws).
// If we got a message, show it as a toast.
if (result?.message) {
toast.error(result.message);
}
return result;
},
null
);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Project Name</label>
<input
id="name"
name="name"
type="text"
placeholder="my-awesome-project"
/>
{/* Show field-level validation errors */}
{state?.fieldErrors?.name && (
<p className="text-red-500 text-sm mt-1">
{state.fieldErrors.name}
</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Project"}
</button>
</form>
);
}
What the user sees for each error case:
| Error Code | User Experience |
|---|---|
VALIDATION | Red text appears under the input field: "Name is too short" |
UNAUTHENTICATED | Redirected to /login (handled via server action redirect) |
FORBIDDEN | Toast notification: "Free plan allows max 3 projects" |
CONFLICT | Toast notification: "A project named X already exists" |
INTERNAL | Toast notification: "Something went wrong" |
What you'll learn: How the same service powers completely different user experiences across server actions, API routes, and the UI.
Why this section matters: This is the "aha moment." One service, many consumers, zero duplication.
Future Entry Points: Background Jobs, CLIs, Webhooks
Remember how the service has no framework dependencies? That means adding new consumers is trivial:
// jobs/nightly-project-setup.ts — Background Job
import { createProject } from "@/services/project-service";
async function nightlySetup(userId: string) {
const result = await createProject({
name: `auto-project-${Date.now()}`,
userId,
userPlan: "pro",
currentProjectCount: 0,
});
if (!result.ok) {
// Log to monitoring, send Slack alert — no toasts, no redirects
logger.error(`Auto-project failed: [${result.error.code}] ${result.error.message}`);
alertOps(`Project creation failed for user ${userId}`);
return;
}
logger.info(`Auto-project created: ${result.data.id}`);
}
No rewriting business logic. No copy-pasting validation rules. You just call the service and handle the Result. That is the dream.
Junior vs. Senior: Side by Side
| Aspect | Junior Approach | Senior Approach |
|---|---|---|
| Business logic | Scattered across server actions, API routes | Centralized in a service layer |
| Error types | Ad-hoc strings ("Something went wrong") | Typed error codes (ErrorCode union) |
| Adding new errors | Grep the codebase and pray | TypeScript compiler flags every missed case |
| New entry point | Copy-paste the entire block | Call the service, handle the Result |
| Testing | Mock Next.js internals | Test the service in isolation — pure functions |
| Framework coupling | redirect() inside business logic | Service is framework-agnostic |
What you'll learn: A concrete comparison of the two approaches.
Why this section matters: Next time someone asks "why is this over-engineered?", show them this table.
Optional Upgrade: Using neverthrow for Structured Flows
If you're the kind of developer who loves functional patterns (no judgment, we all have our hobbies), the neverthrow library formalizes the Result pattern with chainable methods.
npm install neverthrow
Here's the same service rewritten with neverthrow:
// services/project-service-neverthrow.ts
import { ok, err, ResultAsync } from "neverthrow";
import { db } from "@/lib/db";
// Define the error shape
interface AppError {
code: "VALIDATION" | "UNAUTHENTICATED" | "FORBIDDEN" | "CONFLICT" | "INTERNAL";
message: string;
}
interface CreateProjectInput {
name: string;
userId: string | null;
userPlan: "free" | "pro";
currentProjectCount: number;
}
interface Project {
id: string;
name: string;
}
function validateInput(input: CreateProjectInput) {
if (!input.name || input.name.trim().length < 3) {
return err({ code: "VALIDATION" as const, message: "Name is too short." });
}
return ok(input);
}
function checkAuth(input: CreateProjectInput) {
if (!input.userId) {
return err({ code: "UNAUTHENTICATED" as const, message: "Login required." });
}
return ok(input);
}
function checkPermission(input: CreateProjectInput) {
if (input.userPlan === "free" && input.currentProjectCount >= 3) {
return err({ code: "FORBIDDEN" as const, message: "Upgrade your plan." });
}
return ok(input);
}
export function createProject(input: CreateProjectInput): ResultAsync<Project, AppError> {
// Chain validation steps — short-circuits on first error
const validated = validateInput(input)
.andThen(checkAuth)
.andThen(checkPermission);
if (validated.isErr()) {
return ResultAsync.fromResult(validated) as ResultAsync<Project, AppError>;
}
// Async DB operations wrapped in ResultAsync
return ResultAsync.fromPromise(
(async () => {
const existing = await db.project.findFirst({
where: { name: input.name.trim() },
});
if (existing) {
throw { code: "CONFLICT", message: `"${input.name}" already exists.` };
}
const project = await db.project.create({
data: { name: input.name.trim(), ownerId: input.userId! },
});
return { id: project.id, name: project.name };
})(),
(e): AppError => {
if (typeof e === "object" && e !== null && "code" in e) return e as AppError;
return { code: "INTERNAL", message: "Something went wrong." };
}
);
}
And consuming it with match:
const result = await createProject(input);
result.match(
(project) => {
// Happy path — redirect, return JSON, log, whatever
console.log("Created:", project.id);
},
(error) => {
// Sad path — each consumer handles differently
console.error(`[${error.code}] ${error.message}`);
}
);
neverthrow: Pros & Cons
| Roll Your Own | neverthrow | |
|---|---|---|
| Dependencies | Zero | One small package |
| Learning curve | Minimal (just if/else) | Moderate (functional API) |
| Chaining | Manual | Built-in (andThen, map, mapErr) |
| Async support | DIY | ResultAsync out of the box |
| Team adoption | Easy sell | Harder sell to non-FP devs |
| Type safety | Great | Great |
My take: Roll your own for simple projects. Use neverthrow when you have complex pipelines with 5+ steps that each might fail. Don't use it just to look clever in code reviews.
What you'll learn: How a library can formalize the Result pattern with chaining and matching.
Why this section matters: It's good to know the ecosystem. Choose the right tool — sometimes a hammer, sometimes a power drill.
Checklist: Rules of Thumb for Scalable Error Handling
Print this out. Tape it to your monitor. Tattoo it on your forearm if you have to.
- Never put business logic in entry points. Server actions, API routes, and webhooks are adapters, not owners of logic.
- Return errors, don't throw them (for expected failures). Save
throwfor truly unexpected crashes. - Use a typed error code union. String literal unions +
assertNever= compile-time safety. - Map error codes to responses at the boundary. One
httpStatusMapobject. Oneswitchstatement. Done. - Keep the service framework-agnostic. If your service imports
next/navigation, something went wrong. - Test the service, not the framework. Your service is a pure-ish function. Test it with plain unit tests. Don't spin up Next.js to test validation logic.
- Add
assertNeverto every error switch. Future-you will thank present-you. - Use
fieldErrorsfor forms,messagefor toasts. Different UIs need different error shapes. YourResulttype should support both.
Summary
Here's the TL;DR for the "I'm in a meeting and need to look like I read the article" crowd:
- The Problem: Inline error handling creates duplication that rots your codebase from the inside out.
- The Fix: A service layer that returns typed
Resultobjects instead of throwing exceptions. - The Superpower: Discriminated unions +
assertNever= the compiler catches every unhandled case. - The Payoff: One service, many consumers (server actions, API routes, jobs, CLIs) — zero duplicated logic.
- The Optional Upgrade:
neverthrowfor chainable, functional-style error pipelines when complexity demands it.
Error handling isn't glamorous. Nobody writes blog posts titled "I Fixed Our Error Handling and Got a Promotion" (okay, maybe I just did). But it's the difference between a codebase that scales gracefully and one that falls apart every time someone adds a new feature.
Now go forth and handle your errors like the senior engineer you are!
