Error Handling in NestJS: Keep it Simple, Stupid!
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.
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."
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 |
|---|---|
| 1 | User triggers the duplication (e.g., a button press). |
| 2 | The 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) |
|---|---|
| 3 | apiFetch sends the request to the NestJS backend. |
| 4 | The 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 |
|---|---|
| 5 | NestJS returns a 404 JSON response. |
{ "message": "Failed to duplicate post", "error": "Not Found", "statusCode": 404 }
| # | Description |
|---|---|
| 6 | apiFetch 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 |
|---|---|
| 7 | React Query’s useMutation triggers onError with that error. |
| 8 | The 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.
