Every NestJS API eventually develops the same boilerplate. One controller returns { success: true, data: user }, another returns just the raw object, a third throws a plain string error. By the time the frontend team asks "why does /users look different from /products?", you already know it's going to be a painful refactor.
nestjs-api-forge solves this in two lines of setup — one module import and one pipe registration.
What It Does
Register ApiForgeModule once and every response from every controller is automatically shaped into a consistent envelope, every error is structured identically, and every validation failure returns typed field-level details — without touching your controllers at all.
| Layer | What Forge provides |
|---|---|
| 📦 Response envelope | Every handler gets success, statusCode, message, data, meta
|
| 🛡️ Exception filter | Handles HttpException, validation errors, and crashes uniformly |
| ✅ Validation pipe | Typed field-level error details with nested dot-notation paths |
| 📄 Pagination helpers |
ApiResponseDto.paginated() with full pagination meta in one call |
| 🏷️ Per-route decorators | Override messages, skip wrapping, attach custom meta, mark deprecated |
| 🔍 Request tracing | Correlation ID passthrough, auto UUID generation, response time |
| 💥 Typed exceptions | Drop-in replacements for every HttpException with machine-readable error codes |
Installation
npm install nestjs-api-forge
Peer dependencies (already in any NestJS project):
@nestjs/common ^9 | ^10 | ^11
@nestjs/core ^9 | ^10 | ^11
reflect-metadata ^0.1 | ^0.2
rxjs ^7
Two-Step Setup
Step 1 — Register the module in app.module.ts
import { Module } from '@nestjs/common';
import { ApiForgeModule } from 'nestjs-api-forge';
@Module({
imports: [
ApiForgeModule.forRoot({
version: '1.0.0',
defaultSuccessMessage: 'Request successful',
includePath: true,
includeTimestamp: true,
includeRequestId: true,
includeResponseTime: true,
correlationIdHeader: 'x-request-id',
}),
],
})
export class AppModule {}
Step 2 — Add ForgeValidationPipe in main.ts
import { NestFactory } from '@nestjs/core';
import { ForgeValidationPipe } from 'nestjs-api-forge';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ForgeValidationPipe());
await app.listen(3000);
}
bootstrap();
That's the entire setup. Every response is now consistent. You don't change a single controller.
What Every Response Looks Like
Success (2xx)
{
"success": true,
"statusCode": 200,
"message": "User fetched successfully",
"data": {
"id": 1,
"name": "Alice Johnson",
"email": "alice@example.com"
},
"meta": {
"timestamp": "2025-01-15T10:00:00.000Z",
"path": "/api/users/1",
"version": "1.0.0",
"requestId": "a3f2c1d0-84e5-4b6a-9123-abc123def456",
"responseTime": "4ms"
}
}
Error (4xx / 5xx)
{
"success": false,
"statusCode": 404,
"message": "User not found",
"error": {
"code": "NOT_FOUND"
},
"meta": {
"timestamp": "2025-01-15T10:00:00.000Z",
"path": "/api/users/99",
"requestId": "b1e2f3a4-0000-4b5c-8d9e-fedcba987654",
"responseTime": "2ms"
}
}
Validation Error
Before nestjs-api-forge, validation errors from NestJS look like this:
{
"statusCode": 400,
"message": ["email must be an email", "name must be a string"],
"error": "Bad Request"
}
With nestjs-api-forge, they look like this:
{
"success": false,
"statusCode": 400,
"message": "Validation failed",
"error": {
"code": "VALIDATION_ERROR",
"details": [
{ "field": "email", "message": "must be an email" },
{ "field": "address.zip", "message": "must be a string" }
]
},
"meta": {
"timestamp": "2025-01-15T10:00:00.000Z",
"path": "/api/users"
}
}
Every field error has a field name and a message. Nested objects use dot-notation (address.zip). The frontend can map errors directly to form fields without any parsing logic.
Paginated Response
{
"success": true,
"statusCode": 200,
"message": "Users fetched successfully",
"data": [
{ "id": 1, "name": "Alice Johnson" },
{ "id": 2, "name": "Bob Smith" }
],
"pagination": {
"total": 42,
"page": 2,
"limit": 10,
"totalPages": 5,
"hasNextPage": true,
"hasPrevPage": true
},
"meta": {
"timestamp": "2025-01-15T10:00:00.000Z",
"path": "/api/users?page=2&limit=10",
"responseTime": "8ms"
}
}
Decorators
The five decorators let you fine-tune behavior at the controller or method level without touching module config.
@ForgeMessage — Custom success message
import { Controller, Get, Param } from '@nestjs/common';
import { ForgeMessage, NotFoundException } from 'nestjs-api-forge';
@Controller('users')
export class UsersController {
@Get(':id')
@ForgeMessage('User fetched successfully')
findOne(@Param('id') id: number) {
const user = this.usersService.findById(id);
if (!user) throw new NotFoundException('User not found');
return user;
}
}
@ForgeRawResponse — Skip wrapping
Use this when you need to return a raw value — a health check, a file download, or a manually built ApiResponseDto:
@Get('health')
@ForgeRawResponse()
health() {
return { status: 'ok', uptime: process.uptime() };
}
Also the right choice when building paginated responses manually:
@Get()
@ForgeRawResponse()
findAll(@Query('page') page = '1', @Query('limit') limit = '10') {
const p = parseInt(page, 10);
const l = parseInt(limit, 10);
const { data, total } = this.usersService.findAll(p, l);
return ApiResponseDto.paginated(data, total, p, l, 'Users fetched');
}
@ForgeMeta — Custom meta fields
Attach arbitrary key-value pairs to the meta block on a per-route basis:
@Get()
@ForgeMeta({ region: 'us-east-1', cache: 'miss' })
findAll() {
return this.usersService.findAll();
}
// → meta: { ..., region: "us-east-1", cache: "miss" }
@ForgeDeprecated — Deprecation flag
Adds meta.deprecated: true and a Deprecation: true response header — works with API gateways and documentation tools that inspect headers:
@Get('export')
@ForgeDeprecated('Use POST /v2/users/export instead')
@ForgeMessage('Export started')
startExport() {
return this.usersService.queueExport();
}
{
"meta": {
"deprecated": true,
"deprecationNotice": "Use POST /v2/users/export instead"
}
}
@ApiForge — Per-controller scope
If you don't want to register globally and prefer scoped setup per controller:
@Controller('products')
@ApiForge({ version: '2.0', includeResponseTime: true })
export class ProductsController {
// This controller gets its own interceptor + filter
}
Typed Exceptions
Every standard HTTP error has a typed class that extends ApiException, which in turn extends NestJS's HttpException. They all produce the same structured error body — code is always machine-readable.
import {
NotFoundException,
ConflictException,
UnauthorizedException,
TooManyRequestsException,
} from 'nestjs-api-forge';
// Basic
throw new NotFoundException('User not found');
// With field details
throw new BadRequestException('Invalid input', [
{ field: 'price', message: 'Must be a positive number', value: -5 },
]);
// From class-validator constraint map
throw ValidationException.fromConstraints({
email: { isEmail: 'must be an email' },
age: { min: 'must be at least 1' },
});
Full exception reference:
| Class | Status | Error Code |
|---|---|---|
BadRequestException |
400 | BAD_REQUEST |
UnauthorizedException |
401 | UNAUTHORIZED |
PaymentRequiredException |
402 | PAYMENT_REQUIRED |
ForbiddenException |
403 | FORBIDDEN |
NotFoundException |
404 | NOT_FOUND |
MethodNotAllowedException |
405 | METHOD_NOT_ALLOWED |
ConflictException |
409 | CONFLICT |
UnprocessableEntityException |
422 | UNPROCESSABLE_ENTITY |
TooManyRequestsException |
429 | TOO_MANY_REQUESTS |
InternalServerException |
500 | INTERNAL_SERVER_ERROR |
ServiceUnavailableException |
503 | SERVICE_UNAVAILABLE |
GatewayTimeoutException |
504 | GATEWAY_TIMEOUT |
ValidationException |
400 | VALIDATION_ERROR |
Request Tracing & Correlation IDs
In a microservice or load-balanced environment, you need request IDs that flow through every log line and every downstream service call.
Configure once:
ApiForgeModule.forRoot({
includeRequestId: true, // auto-generate UUID if no header present
correlationIdHeader: 'x-request-id', // or an array of headers to check
includeResponseTime: true,
})
Send the header from the client:
GET /api/users/1
X-Request-ID: my-trace-id-abc123
The ID appears in meta.requestId in the response body and is echoed back in the x-request-id response header — so the caller can correlate logs end-to-end without any middleware changes.
Async Config (forRootAsync)
When your options come from a config service:
ApiForgeModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
version: config.get('API_VERSION'),
includeRequestId: true,
includeResponseTime: true,
correlationIdHeader: config.get('CORRELATION_HEADER'),
defaultSuccessMessage: config.get('SUCCESS_MESSAGE'),
}),
})
How It Works Internally
The library is three NestJS primitives composed together:
ForgeResponseInterceptor — an NestInterceptor that wraps the RxJS observable returned by every handler. It reads the decorator metadata via Reflector, builds the meta block, resolves or generates the request ID, and maps the raw handler value into ApiResponseDto.success(...).
ForgeExceptionFilter — a @Catch() filter that intercepts every thrown exception. ApiException subclasses are handled directly (structured code + details). Standard HttpException instances are parsed and normalized. Anything else becomes a generic 500. 5xx errors are logged with Logger.error; 4xx with Logger.warn.
ForgeValidationPipe — extends NestJS ValidationPipe behaviour. Instead of returning a flat message array, it maps class-validator constraint objects into { field, message } pairs with support for nested paths.
The @ApiForge() decorator is a shorthand that calls applyDecorators(UseInterceptors(...), UseFilters(...)) so you get both primitives scoped to one controller without registering globally.
ApiForgeModule.forRoot Options Reference
| Option | Type | Default | Description |
|---|---|---|---|
includePath |
boolean |
true |
Request path in meta
|
includeTimestamp |
boolean |
true |
ISO timestamp in meta
|
includeRequestId |
boolean |
false |
Auto-generate UUID if no correlation header |
correlationIdHeader |
`string \ | string[]` | ['x-request-id', 'x-correlation-id'] |
includeResponseTime |
boolean |
false |
Handler duration as meta.responseTime
|
version |
string |
undefined |
API version string in meta
|
defaultSuccessMessage |
string |
'Request successful' |
Fallback success message |
ApiResponseDto Static Builders
Use these when building responses manually (e.g. inside a @ForgeRawResponse() method):
import { ApiResponseDto } from 'nestjs-api-forge';
ApiResponseDto.success(data, 'OK', 200, meta);
ApiResponseDto.created(data, 'User created');
ApiResponseDto.accepted(jobRef, 'Export queued'); // 202
ApiResponseDto.noContent('Deleted'); // 204
ApiResponseDto.paginated(data, total, page, limit, 'Users fetched');
ApiResponseDto.error('Not found', 404, { code: 'NOT_FOUND' }, meta);
A Complete Controller Example
import {
Controller, Get, Post, Delete,
Param, Body, Query, HttpCode, HttpStatus,
} from '@nestjs/common';
import {
ForgeMessage,
ForgeRawResponse,
ForgeMeta,
ForgeDeprecated,
NotFoundException,
ConflictException,
ApiResponseDto,
} from 'nestjs-api-forge';
import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
@ForgeRawResponse()
findAll(@Query('page') page = '1', @Query('limit') limit = '10') {
const p = +page, l = +limit;
const { data, total } = this.usersService.findAll(p, l);
return ApiResponseDto.paginated(data, total, p, l, 'Users fetched');
}
@Get(':id')
@ForgeMessage('User fetched successfully')
findOne(@Param('id') id: number) {
const user = this.usersService.findById(id);
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}
@Post()
@ForgeMessage('User created successfully')
@ForgeMeta({ source: 'registration-flow' })
create(@Body() dto: CreateUserDto) {
const exists = this.usersService.findByEmail(dto.email);
if (exists) throw new ConflictException('Email already registered');
return this.usersService.create(dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id') id: number) {
this.usersService.remove(id);
}
@Get('export')
@ForgeDeprecated('Use POST /v2/users/export instead')
@ForgeMessage('Export queued')
startExport() {
return this.usersService.queueExport();
}
}
Links
- npm: npmjs.com/package/nestjs-api-forge
- GitHub: github.com/mirzasaikatahmmed/nestjs-api-forge
- Author: Mirza Saikat Ahmmed
If you've ever copy-pasted a { success, data, message } wrapper across three controllers, give nestjs-api-forge a try. One import, two lines of setup, zero boilerplate from that point forward.
What's your current approach to consistent API responses in NestJS? Drop a comment — I'd love to see what patterns others are using.
Top comments (0)