NestJS Error Handling: Stop Sending Ugly 500 Errors to Your Users
Learn how to handle errors properly in NestJS using exception filters, custom exceptions, and a global error response format that makes your API actually usable.
Introduction To Nest JS
Part 7 of 6
Table of Contents
Here’s what happens when something goes wrong in a NestJS app that hasn’t been set up for proper error handling.
Your user hits an endpoint. Something breaks — maybe a database query fails, maybe a record doesn’t exist, maybe there’s a validation issue. And they get back something like this:
{
"statusCode": 500,
"message": "Internal server error"
}
Or worse, a full stack trace leaking internal details about your server to whoever is on the other end.
Neither is acceptable in a real API. Your frontend developers don’t know what went wrong. Your users definitely don’t. And if it’s a stack trace, you’ve just handed someone a map of your application internals.
The good news is NestJS has a really clean system for handling this. Once it’s set up, every error in your entire API goes through one place and comes out looking exactly the way you want it to.
This builds on what we’ve covered in the series so far. If you haven’t read the article on JWT authentication or modules and services, some of the patterns here will make more sense with that context.
📋 What you'll learn
- ✓ How NestJS handles errors by default and why it's not enough
- ✓ How to build a global exception filter that catches everything
- ✓ How to create custom exception classes for your own error types
- ✓ How to send consistent, structured error responses across your entire API
- ✓ How to log errors properly without leaking sensitive information
How NestJS Handles Errors by Default
NestJS ships with a built-in global exception filter that catches any unhandled exception and turns it into an HTTP response.
For exceptions it recognizes — like NotFoundException, UnauthorizedException, BadRequestException — it returns the right status code and a message.
For anything it doesn’t recognize — like an unexpected database error or a plain JavaScript Error — it returns a generic 500.
That behavior is fine as a safety net. But it gives you no control over the response format, no logging, and no way to distinguish between different types of unexpected errors.
Here’s what the default response looks like for a NotFoundException:
{
"statusCode": 404,
"message": "User with id abc not found",
"error": "Not Found"
}
And for a raw unhandled error:
{
"statusCode": 500,
"message": "Internal server error"
}
The format isn’t consistent. The first one has error, the second doesn’t. If your frontend is parsing these responses, it has to handle two different shapes. That’s messy.
What we want is one consistent shape for every error, every time.
Building a Global Exception Filter
An exception filter in NestJS is a class that catches exceptions and decides what the HTTP response looks like. A global exception filter catches everything — every unhandled exception from every controller in your entire app.
Create a new file for it:
// src/common/filters/global-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
The @Catch() decorator with no arguments means this filter catches everything — not just HttpException but any unhandled error anywhere in the app:
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
Now figure out the status code and message depending on what kind of exception it is:
const isHttpException = exception instanceof HttpException;
const statusCode = isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = isHttpException
? exception.message
: 'Something went wrong on our end. Please try again later.';
Log the error — but only log the full stack trace for unexpected errors, not for things like 404s or validation errors:
if (!isHttpException) {
// Unexpected error — log the full details for debugging
this.logger.error(
`Unexpected error on ${request.method} ${request.url}`,
exception instanceof Error ? exception.stack : String(exception),
);
}
Finally, send the response in a consistent shape:
response.status(statusCode).json({
statusCode,
message,
path: request.url,
timestamp: new Date().toISOString(),
});
}
}
Every error in your API now returns the same structure. Your frontend knows exactly what to expect.
Registering the Filter Globally
You can register it in main.ts so it applies to every single route without touching individual controllers:
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new GlobalExceptionFilter());
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.listen(3000);
}
bootstrap();
One line. Every error in your entire application is now handled consistently.
NestJS Built-in Exceptions
Before you start creating custom exceptions for everything, it’s worth knowing what NestJS already ships with. These cover most of what you’ll need:
throw new BadRequestException('Email is already taken'); // 400
throw new UnauthorizedException('Invalid credentials'); // 401
throw new ForbiddenException('You cannot access this'); // 403
throw new NotFoundException('Post not found'); // 404
throw new ConflictException('Username already exists'); // 409
throw new UnprocessableEntityException('Invalid data format'); // 422
throw new InternalServerErrorException('Database query failed');// 500
Throw any of these from anywhere in your service or controller and NestJS catches it, your global filter intercepts it, and the right status code goes back to the client.
Use these before reaching for custom exceptions. They handle the vast majority of API error cases.
Creating Custom Exception Classes
Sometimes the built-in exceptions aren’t specific enough. Maybe you want a ResourceExpiredException for subscription logic, or a PaymentRequiredException for a paywalled feature.
Custom exceptions in NestJS are just classes that extend HttpException:
// src/common/exceptions/resource-expired.exception.ts
import { HttpException, HttpStatus } from '@nestjs/common';
export class ResourceExpiredException extends HttpException {
constructor(resource: string) {
super(
`${resource} has expired. Please renew to continue.`,
HttpStatus.GONE, // 410
);
}
}
Use it anywhere in your app:
// in any service
if (subscription.expiresAt < new Date()) {
throw new ResourceExpiredException('Your subscription');
}
The global filter catches it, reads the status code and message from the exception, and formats it consistently. You don’t need to add any special handling for custom exceptions — the filter handles them automatically because they extend HttpException.
Handling Validation Errors
When ValidationPipe rejects a request because the body doesn’t match your DTO, it throws a BadRequestException with an array of validation error messages.
The default response looks like this:
{
"statusCode": 400,
"message": ["email must be an email", "password must be longer than 8 characters"],
"error": "Bad Request"
}
That’s useful, but the format is different from our global error shape. Let’s make validation errors consistent too.
Update your global filter to handle the case where message might be an array:
const rawResponse = isHttpException ? exception.getResponse() : null;
const message = (() => {
if (!isHttpException) return 'Something went wrong on our end. Please try again later.';
if (typeof rawResponse === 'string') return rawResponse;
if (typeof rawResponse === 'object' && rawResponse !== null) {
const res = rawResponse as Record<string, unknown>;
// ValidationPipe returns { message: string[] } for validation errors
if (Array.isArray(res.message)) return res.message.join(', ');
if (typeof res.message === 'string') return res.message;
}
return exception.message;
})();
Now validation errors come back as a single readable string instead of an array, in the same shape as every other error.
The Error Response Shape Your Frontend Will Thank You For
After all of this, every error in your API returns this:
{
"statusCode": 404,
"message": "User with id abc-123 not found",
"path": "/api/users/abc-123",
"timestamp": "2026-05-21T10:30:00.000Z"
}
statusCode — the HTTP status code, also right there in the body so clients that don’t read headers can use it.
message — a human-readable description of what went wrong. Clear enough for developers, safe enough that it doesn’t leak internals.
path — which endpoint triggered the error. Useful for debugging and for frontend error logging.
timestamp — when it happened. Essential for correlating errors with logs.
This is the kind of response format that makes your API feel professional. It’s also the kind of thing that frontend developers mention when they say an API is a pleasure to work with.
A Common Mistake to Avoid
Don’t throw plain JavaScript errors in your services expecting them to become meaningful HTTP responses:
// ❌ This becomes a 500 with "Internal server error" — no useful info
throw new Error('User not found');
// ✅ This becomes a clean 404 with your message
throw new NotFoundException('User not found');
Plain Error objects aren’t HttpException instances. Your global filter will catch them but it can’t extract a meaningful status code or message — so it defaults to 500 and logs the stack trace. Always use NestJS exceptions in your service layer.
What You Have Now
A global exception filter that catches every unhandled error, formats it consistently, logs unexpected errors with enough detail to debug them, and never leaks internal information to the client.
Your API now has a single, predictable error shape. Your frontend developers know what to expect. And when something genuinely breaks in production, you have a log entry with the full context to debug it.
Next we’re going to look at environment variables and configuration in NestJS — how to manage them properly across development, staging, and production so you’re not hardcoding things that should never be hardcoded.
Drop any questions in the comments. Error handling is one of those things that seems simple until you hit an edge case, so if something in your project isn’t behaving the way you expect, I’m happy to help figure it out.
Continue the series →
Next up: NestJS Modules, Controllers and Services — the architecture finally explained.
Read next article →Related Posts
NestJS for Express Developers: What Actually Changes
Already know Express? Learn how NestJS builds on top of it with structure, TypeScript-first design, and built-in architecture — without throwing away what you already know.
NestJS + PostgreSQL with TypeORM: Complete Setup Guide
Learn how to connect NestJS to PostgreSQL using TypeORM — from installation to entities, relations, and repositories. With real code and the mistakes you'll actually make.
Environment Variables in NestJS: The Right Way with @nestjs/config
Learn how to manage environment variables in NestJS properly — from basic setup to validation, typed config, and multiple environments — so your app never breaks because of a missing secret.