NestJS: Een complete REST API vanaf nul bouwen
Stap-voor-stap handleiding voor het bouwen van een productieklare REST API met NestJS, TypeScript, Prisma en class-validator. CRUD, validatie, foutafhandeling en interceptors.

NestJS heeft zich gevestigd als het toonaangevende Node.js-framework voor het bouwen van schaalbare server-side applicaties. De modulaire architectuur, het ingebouwde dependency injection systeem en de volledige TypeScript-ondersteuning maken NestJS de ideale keuze voor professionele REST API's. Met NestJS 11 worden ontwikkelsnelheid en runtime-prestaties nog verder verbeterd.
NestJS 11 brengt geoptimaliseerd module-scanning, snellere opstarttijden en verbeterde decorators. Het framework combineert de beste concepten van Angular (decorators, modules, DI) met de flexibiliteit van Node.js en biedt daarmee een eersteklas ontwikkelervaring voor backend-projecten.
Projectinstallatie en configuratie
De NestJS CLI genereert een vooraf geconfigureerd project met TypeScript, ESLint en een doordachte mappenstructuur. De opstart vereist slechts enkele commando's.
# terminal
# Global installation of the NestJS CLI
npm install -g @nestjs/cli
# Create a new project
nest new my-api
# Navigate to the project
cd my-api
# Start in development mode with hot-reload
npm run start:devHet bestand main.ts is het startpunt van de applicatie. Hier worden globale pipes, het routeprefix en andere configuraties ingesteld.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
// Create the NestJS application
const app = await NestFactory.create(AppModule);
// Enable global validation
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Removes non-decorated properties
forbidNonWhitelisted: true, // Rejects requests with unknown properties
transform: true, // Transforms payloads to DTO instances
}));
// Configure global prefix for all routes
app.setGlobalPrefix('api');
await app.listen(3000);
}
bootstrap();De ValidationPipe met whitelist: true verwijdert automatisch velden die niet in het DTO zijn gedefinieerd. Met forbidNonWhitelisted: true wordt een foutmelding gegeven wanneer onbekende velden worden meegestuurd. Dit beschermt de API tegen ongewenste data.
De modulaire architectuur begrijpen
NestJS organiseert code in modules. Elke module kapselt gerelateerde controllers, services en entiteiten in. De rootmodule importeert alle featuremodules en vormt het startpunt van de applicatie.
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { ProductsModule } from './products/products.module';
import { AuthModule } from './auth/auth.module';
// The root module imports all application modules
@Module({
imports: [
UsersModule, // User management
ProductsModule, // Product catalog
AuthModule, // Authentication
],
})
export class AppModule {}Elke featuremodule volgt hetzelfde patroon: controllers voor HTTP-verzoeken, services voor bedrijfslogica en exports voor gebruik tussen modules.
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
// Controllers handle HTTP requests
controllers: [UsersController],
// Providers are injectable throughout the module
providers: [UsersService],
// Exports make providers available to other modules
exports: [UsersService],
})
export class UsersModule {}Deze structuur dwingt een duidelijke scheiding van verantwoordelijkheden af. De UsersService wordt via dependency injection in de controller geïnjecteerd. Dankzij de exports-array kan de service ook in andere modules worden gebruikt, zoals de AuthModule.
Een complete CRUD-controller bouwen
De controller definieert de HTTP-endpoints en delegeert de bedrijfslogica aan de service. NestJS-decorators zoals @Get(), @Post(), @Put() en @Delete() koppelen methoden aan de bijbehorende HTTP-werkwoorden.
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
// Route prefix: /api/users
@Controller('users')
export class UsersController {
// Inject service via constructor
constructor(private readonly usersService: UsersService) {}
// POST /api/users - Create a user
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() createUserDto: CreateUserDto): Promise<User> {
// The DTO is automatically validated before reaching here
return this.usersService.create(createUserDto);
}
// GET /api/users - List with pagination
@Get()
async findAll(
@Query('page', new ParseIntPipe({ optional: true })) page: number = 1,
@Query('limit', new ParseIntPipe({ optional: true })) limit: number = 10,
): Promise<{ data: User[]; total: number }> {
return this.usersService.findAll(page, limit);
}
// GET /api/users/:id - Retrieve by ID
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number): Promise<User> {
// ParseIntPipe automatically converts and validates the parameter
return this.usersService.findOne(id);
}
// PUT /api/users/:id - Full update
@Put(':id')
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
): Promise<User> {
return this.usersService.update(id, updateUserDto);
}
// DELETE /api/users/:id - Deletion
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
await this.usersService.remove(id);
}
}De ParseIntPipe converteert stringparameters automatisch naar getallen en geeft een 400-fout terug als de waarde geen geldig getal is. NestJS biedt meer ingebouwde pipes zoals ParseBoolPipe, ParseArrayPipe en ParseUUIDPipe voor veelvoorkomende conversies.
Implementatie van de businessservice
De service bevat alle bedrijfslogica en is door de @Injectable()-decorator gemarkeerd als provider. In deze eerste versie wordt een in-memory array als dataopslag gebruikt, voordat later de integratie met Prisma volgt.
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';
@Injectable()
export class UsersService {
// In-memory database simulation
private users: User[] = [];
private idCounter = 1;
async create(createUserDto: CreateUserDto): Promise<User> {
// Create the user entity
const user: User = {
id: this.idCounter++,
...createUserDto,
createdAt: new Date(),
updatedAt: new Date(),
};
this.users.push(user);
return user;
}
async findAll(page: number, limit: number): Promise<{ data: User[]; total: number }> {
// Calculate pagination
const start = (page - 1) * limit;
const end = start + limit;
return {
data: this.users.slice(start, end),
total: this.users.length,
};
}
async findOne(id: number): Promise<User> {
const user = this.users.find(u => u.id === id);
// Throw exception if user doesn't exist
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id);
// Merge existing data with updates
Object.assign(user, updateUserDto, { updatedAt: new Date() });
return user;
}
async remove(id: number): Promise<void> {
const index = this.users.findIndex(u => u.id === id);
if (index === -1) {
throw new NotFoundException(`User with ID ${id} not found`);
}
this.users.splice(index, 1);
}
// Utility method for other services
async findByEmail(email: string): Promise<User | undefined> {
return this.users.find(u => u.email === email);
}
}Klaar om je Node.js / NestJS gesprekken te halen?
Oefen met onze interactieve simulatoren, flashcards en technische tests.
De service gebruikt NotFoundException uit @nestjs/common om standaard 404-fouten te genereren. NestJS zet deze exceptions automatisch om in de juiste HTTP-responses.
Datavalidatie met class-validator
DTO's (Data Transfer Objects) definiëren de structuur van binnenkomende data en gebruiken decorators van class-validator voor validatie. Het CreateUserDto specificeert welke velden verplicht zijn bij het aanmaken van een gebruiker.
import {
IsEmail,
IsNotEmpty,
IsString,
MinLength,
MaxLength,
IsOptional,
Matches,
} from 'class-validator';
export class CreateUserDto {
@IsNotEmpty({ message: 'Email is required' })
@IsEmail({}, { message: 'Invalid email format' })
email: string;
@IsNotEmpty({ message: 'Password is required' })
@MinLength(8, { message: 'Password must be at least 8 characters' })
@MaxLength(50, { message: 'Password cannot exceed 50 characters' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
{ message: 'Password must contain at least one uppercase, one lowercase, and one number' }
)
password: string;
@IsNotEmpty({ message: 'First name is required' })
@IsString()
@MinLength(2)
@MaxLength(50)
firstName: string;
@IsNotEmpty({ message: 'Last name is required' })
@IsString()
@MinLength(2)
@MaxLength(50)
lastName: string;
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
}Het UpdateUserDto gebruikt PartialType en OmitType om alle velden optioneel te maken en het wachtwoord uit te sluiten van standaard updates.
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';
// All fields from CreateUserDto become optional
// Password is excluded from standard updates
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['password'] as const)
) {}De User-entiteit definieert de volledige datastructuur inclusief automatisch gegenereerde velden.
export class User {
id: number;
email: string;
password: string;
firstName: string;
lastName: string;
phone?: string;
createdAt: Date;
updatedAt: Date;
}Gecentraliseerde foutafhandeling
Exception filters vangen fouten op en formatteren de HTTP-responses uniform. De HttpExceptionFilter behandelt bekende HTTP-fouten, terwijl de AllExceptionsFilter onverwachte systeemfouten opvangt.
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
// Catches all HttpExceptions
@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();
// Retrieve error message (can be string or object)
const exceptionResponse = exception.getResponse();
const message = typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message;
// Standardized response format
response.status(status).json({
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
method: request.method,
message: message,
});
}
}import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';
// Catches ALL exceptions (including system errors)
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
// Determine HTTP code and message
const status = exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof HttpException
? exception.message
: 'Internal server error';
// Log error for debugging
console.error('Exception caught:', exception);
response.status(status).json({
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: message,
});
}
}Beide filters worden globaal geregistreerd in het main.ts-bestand.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global validation
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
// Global exception filters
app.useGlobalFilters(
new AllExceptionsFilter(),
new HttpExceptionFilter(),
);
app.setGlobalPrefix('api');
await app.listen(3000);
}
bootstrap();De volgorde van filters bij useGlobalFilters() is bepalend. De laatst geregistreerde filter wordt als eerste uitgevoerd. De HttpExceptionFilter moet na de AllExceptionsFilter staan, zodat HTTP-exceptions specifiek worden afgehandeld voordat de algemene filter ingrijpt.
Integratie met Prisma ORM
Prisma biedt type-safe databasetoegang met automatisch gegenereerde types. De installatie en configuratie met PostgreSQL vereist slechts enkele stappen.
# terminal
# Install Prisma
npm install prisma @prisma/client
# Initialize Prisma with PostgreSQL
npx prisma init --datasource-provider postgresqlHet Prisma-schema definieert de databasemodellen en hun relaties.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
firstName String @map("first_name")
lastName String @map("last_name")
phone String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
posts Post[]
@@map("users")
}
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
authorId Int @map("author_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relation to User
author User @relation(fields: [authorId], references: [id])
@@map("posts")
}De PrismaService kapselt de Prisma-client in en beheert de databaseverbinding via de NestJS lifecycle hooks.
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
// Automatic connection on module startup
async onModuleInit() {
await this.$connect();
}
// Clean disconnection on application shutdown
async onModuleDestroy() {
await this.$disconnect();
}
}De PrismaModule is versierd met @Global() zodat de PrismaService in de hele applicatie beschikbaar is zonder deze in elke module apart te hoeven importeren.
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
// @Global makes the service available throughout the application
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}De bijgewerkte UsersService vervangt de in-memory opslag door Prisma-query's en voegt wachtwoord-hashing en e-mail-uniekheidscontroles toe.
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from '@prisma/client';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async create(createUserDto: CreateUserDto): Promise<Omit<User, 'password'>> {
// Check email uniqueness
const existingUser = await this.prisma.user.findUnique({
where: { email: createUserDto.email },
});
if (existingUser) {
throw new ConflictException('This email is already in use');
}
// Hash the password
const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
// Create the user
const user = await this.prisma.user.create({
data: {
...createUserDto,
password: hashedPassword,
},
});
// Exclude password from response
const { password, ...result } = user;
return result;
}
async findAll(page: number, limit: number): Promise<{ data: User[]; total: number }> {
// Parallel execution of count and paginated query
const [data, total] = await Promise.all([
this.prisma.user.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
createdAt: true,
updatedAt: true,
},
}),
this.prisma.user.count(),
]);
return { data: data as User[], total };
}
async findOne(id: number): Promise<Omit<User, 'password'>> {
const user = await this.prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user as Omit<User, 'password'>;
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<Omit<User, 'password'>> {
// Check existence
await this.findOne(id);
const user = await this.prisma.user.update({
where: { id },
data: updateUserDto,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
createdAt: true,
updatedAt: true,
},
});
return user as Omit<User, 'password'>;
}
async remove(id: number): Promise<void> {
await this.findOne(id);
await this.prisma.user.delete({ where: { id } });
}
}Interceptors voor response-transformatie
Interceptors verwerken verzoeken en responses op een overkoepelend niveau. De TransformInterceptor verpakt alle responses in een uniform formaat, terwijl de LoggingInterceptor uitvoeringstijden vastlegt.
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
// Interface for standardized response format
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const now = Date.now();
return next.handle().pipe(
tap(() => {
const response = context.switchToHttp().getResponse();
const { statusCode } = response;
const duration = Date.now() - now;
// Structured log format
this.logger.log(
`${method} ${url} ${statusCode} - ${duration}ms`
);
}),
);
}
}De TransformInterceptor zorgt ervoor dat alle endpoints hetzelfde responseformaat gebruiken: { success, data, timestamp }. De LoggingInterceptor meet de uitvoeringstijd van elk verzoek en produceert gestructureerde logs die bruikbaar zijn voor monitoringtools.
Conclusie
Deze handleiding heeft alle kernconcepten behandeld die nodig zijn voor het bouwen van een productieklare REST API met NestJS. Van modulaire architectuur tot validatie en database-integratie biedt NestJS een samenhangend totaalpakket.
Checklist voor de REST API
- Een NestJS-project aanmaken met de CLI en de
ValidationPipeglobaal configureren - Code opdelen in featuremodules met een duidelijke scheiding van controller en service
- DTO's definiëren met
class-validator-decorators voor automatische datavalidatie - Gecentraliseerde foutafhandeling opzetten met exception filters
- Prisma integreren voor type-safe databasetoegang
- Interceptors implementeren voor uniforme responseformaten en logging
Begin met oefenen!
Test je kennis met onze gespreksimulatoren en technische tests.
Vanuit deze basis kan de API worden uitgebreid met guards voor authenticatie, Swagger voor API-documentatie en middleware voor rate limiting. De modulaire architectuur van NestJS zorgt ervoor dat de codebase overzichtelijk en onderhoudbaar blijft, ook bij toenemende complexiteit.
Tags
Delen
