Advanced Error Handling in TypeScript + Next.js: Stop Crying in Production

Frontend

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:

  1. Copy-paste try/catch blocks everywhere with slightly different messages.
  2. Framework-specific code (like redirect() or NextResponse.json()) leaking into business logic.
  3. No compiler help when you add a new error type — you have to manually hunt down every catch block.
  4. 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?

  • throw is invisible to the type system. TypeScript doesn't track what a function throws, so you get zero compiler help when errors change.
  • A Result return type makes errors part of the function's contract. If the function says it returns Result<Project>, the caller is forced to handle both ok: true and ok: 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 assertNever enforces 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 CodeUser Experience
VALIDATIONRed text appears under the input field: "Name is too short"
UNAUTHENTICATEDRedirected to /login (handled via server action redirect)
FORBIDDENToast notification: "Free plan allows max 3 projects"
CONFLICTToast notification: "A project named X already exists"
INTERNALToast 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

AspectJunior ApproachSenior Approach
Business logicScattered across server actions, API routesCentralized in a service layer
Error typesAd-hoc strings ("Something went wrong")Typed error codes (ErrorCode union)
Adding new errorsGrep the codebase and prayTypeScript compiler flags every missed case
New entry pointCopy-paste the entire blockCall the service, handle the Result
TestingMock Next.js internalsTest the service in isolation — pure functions
Framework couplingredirect() inside business logicService 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 Ownneverthrow
DependenciesZeroOne small package
Learning curveMinimal (just if/else)Moderate (functional API)
ChainingManualBuilt-in (andThen, map, mapErr)
Async supportDIYResultAsync out of the box
Team adoptionEasy sellHarder sell to non-FP devs
Type safetyGreatGreat

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 throw for 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 httpStatusMap object. One switch statement. 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 assertNever to every error switch. Future-you will thank present-you.
  • Use fieldErrors for forms, message for toasts. Different UIs need different error shapes. Your Result type 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:

  1. The Problem: Inline error handling creates duplication that rots your codebase from the inside out.
  2. The Fix: A service layer that returns typed Result objects instead of throwing exceptions.
  3. The Superpower: Discriminated unions + assertNever = the compiler catches every unhandled case.
  4. The Payoff: One service, many consumers (server actions, API routes, jobs, CLIs) — zero duplicated logic.
  5. The Optional Upgrade: neverthrow for 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!