Error Handling in NestJS: Keep it Simple, Stupid!

Summary

Sooner or later, you'll need to handle those annoying errors! In this post, I'll cover how to do it properly. You'll learn:

  • How to handle errors in NestJS backends
  • How to surface errors in a Next.js frontend

This will help you add proper error handling to your web applications that are easy to develop, and adds value to the users.

By Max Rohowsky, Ph.D.

Handling Errors in NestJS Backends

From a user's perspective, unhandled errors are really annoying. What's worse is if the user doesn't know what to do next if something goes wrong.

Therefore, it's worth handling errors gracefully by surfacing helpful error messages in the UI. That way, users will often be able to act in a meaningful way if something goes wrong by, for example, retrying the action.

So, below, I'll cover how to handle errors in NestJS backends and how to surface them in a Next.js frontend.

🤓 Beginner: Built-in Exceptions

Out of the box, NestJS provides standard HTTP exceptions that inherit from the standard HttpException class. This includes, for example, BadRequestException, NotFoundException, ForbiddenException, etc.

The example below shows a service method to duplicate a post. In the service, NotFoundException is thrown if the duplication fails.

@Injectable()
export class PostsService {
  constructor(private readonly postsRepository: PostsRepository) {}

  async duplicateToDraft(postId: string, userId: string) {
    const duplicated = await this.postsRepository.duplicatePostToDraft(
      postId,
      userId,
    );
    if (!duplicated) {
      throw new NotFoundException('Failed to duplicate post');
    }
    return duplicated;
  }
}

Since we used NotFoundException with the message 'Failed to duplicate', the response looks like this:

{
  "message": "Failed to duplicate",
  "error": "Not Found",
  "statusCode": 404
}

By simply using a built-in HTTP exception, we get a nicely formatted error json.

😉 Intermediate: Exception Filter

If you want to control the json response shape that is sent to the client for all exceptions, you can use a global exception filter.

Use the @Catch() decorator and pass in the exception type you want to catch. The example below shows a global exception filter registered in main.ts that catches all exceptions with type HttpException.

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response.status(status).json({
      message: exception.message,
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

The benefit of this approach is that the json response now includes additional information like the timestamp and the request path.

{
  "message": "Failed to duplicate post",
  "statusCode": 404,
  "timestamp": "2026-01-06T16:48:22.742Z",
  "path": "/api/posts/cmk2tote800005suje3cvfiza/duplicate"
}

😎 Pro: Custom Exceptions

If the built-in exceptions don't fit your needs, you can create custom ones. You probably don't need this, and the authors of the NestJS docs agree:

"In many cases, you will not need to write custom exceptions, and can use the built-in Nest HTTP exceptions."

NestJS Documentation

But if you decide to make a custom exception, it should inherit from the base HttpException class:

export class CustomException extends HttpException {
  // Your custom exception
}

This ensures the built-in global filter recognizes it as an exception and sends a properly formatted HTTP response to the client.

🥷 Ninja: Scoping Filters to Routes

Further above, we covered the use of useGlobalFilters to register a global filter which applies to all routes. It's also possible to scope filters to specific routes.

Here's an example of a filter scoped to a controller:

@Controller('posts')
@UseFilters(new HttpExceptionFilter())
export class PostController {
  // All route handlers in this controller use HttpExceptionFilter
}

This construction sets up the HttpExceptionFilter for every route handler defined inside the PostController.

Showing Errors on the Frontend

After setting up the backend to throw exceptions, let's see how to surface the information to the user on the frontend. Here's an example of what the error could look like:

It only makes sense to show the errors that add value to the user. For example, if a post fails to duplicate, it makes sense to show a message that the user should try the action again.

Below is a simplified example of the flow of events between the user pressing a button and the error being shown to the user. The function names from the description refer to the names in the code snippet below.

#Description
1User triggers the duplication (e.g., a button press).
2The React Query hook useDuplicatePost calls the fetch wrapper apiFetch.
// useDuplicatePost.ts
import { useMutation } from '@tanstack/react-query';
import { apiFetch } from './apiFetch';
import { toast } from '@/components/ui/use-toast'; // or wherever your toast comes from

export function useDuplicatePost() {
  return useMutation({
    mutationFn: ({ postId }: { postId: string }) =>
      apiFetch(`/api/posts/${postId}/duplicate`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
      }),
    onError: (err: Error) => {
       toast({ title: "Error", description: err.message })
    },
  });
}
#Description (assuming failure)
3apiFetch sends the request to the NestJS backend.
4The backend controller calls the service; the service throws a built-in HTTP exception NotFoundException for the failed duplication.
@Controller('posts')
export class PostController {
  constructor(private readonly postsService: PostsService) {}

  @AuthGuard() // For example from Better-Auth
  @Post(':id/duplicate')
  @HttpCode(201)
  async duplicate(
    @UserId() userId: string, 
    @Param('id') postId: string) {
      return await this.postsService.duplicateToDraft(postId, userId);
  }
}
#Description
5NestJS returns a 404 JSON response.
{
  "message": "Failed to duplicate post",
  "error": "Not Found",
  "statusCode": 404
}
#Description
6apiFetch sees !res.ok, tries await res.json(), and throws an Error.
// apiFetch.ts from earlier in the post
export async function apiFetch(input: RequestInfo, init?: RequestInit) {
  const res = await fetch(input, init)

  if (!res.ok) {
    let message = "Request failed"

    try {
      const data = await res.json()
      if (typeof data?.message === "string") message = data.message
    } catch {
      message = `Request failed (${res.status})`
    }

    throw new Error(message)
  }

  return res.json()
}
#Description
7React Query’s useMutation triggers onError with that error.
8The UI displays the error, for example, by showing a toast.

Conclusion

From a user's perspective, the best way to handle an error is to surface a sensible error message that the user can act on.

If you're using NestJS and Next.js, most of your error handling needs are covered with the built-in HTTP exceptions described in the section titled "Beginner: Built-in Exceptions".

There are ways to get fancy with error handling by using global exception filters or custom exceptions. But for most people, these will be less relevant.

Max Rohowsky

Hey, I'm Max.

I'm an Athlete turned Finance Ph.D., Engineer, and Corporate Consultant.