
NestJS Fundamentals: A Beginner's Guide to Backend APIs
Learn NestJS from scratch with controllers, providers, modules, and validation to build structured backend APIs.
NestJS Fundamentals: A Beginner's Guide to Backend APIs
Hello there! If you already build frontends with React or Next.js and want a backend that feels just as typed and organized, NestJS is worth learning. This guide walks through the core building blocks with working code. Use it as a reference while you build your first API.
NestJS is an opinionated Node.js framework for server-side applications. It runs on Express (or Fastify) under the hood, treats TypeScript as a first-class citizen, and borrows the module and decorator style you may know from Angular.
Table of Contents
- Why NestJS?
- Setup
- Project Structure
- Controllers
- Providers and Services
- Modules
- Dependency Injection
- Validation with Pipes
- Error Handling
- Testing
- Best Practices
Why NestJS?
A plain Express app starts fast and turns messy fast. Routes, database calls, and auth checks end up in the same files. NestJS gives every piece a fixed place before the project grows:
// Express - everything in one handler
app.post('/users', async (req, res) => {
if (!req.body.email) return res.status(400).send('Email required');
const user = await db.users.insert(req.body); // validation? auth? mixed in
res.json(user);
});
// NestJS - each concern has its own file
@Controller('users') // routing lives here
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post() // validation handled by a pipe, logic lives in the service
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
}You get dependency injection, modules, and testing utilities without assembling them yourself.
Setup
Install the CLI once, then scaffold a project:
npm i -g @nestjs/cli
nest new my-api
cd my-api
npm run start:devstart:dev watches your files and reloads on save. Open http://localhost:3000 and you should see Hello World! from the default controller.
Project Structure
The CLI generates a small, predictable tree:
src/
app.controller.ts # routes for the root module
app.service.ts # business logic for the root module
app.module.ts # wires controllers and providers together
main.ts # bootstraps the applicationmain.ts is the entry point. It creates the app and starts listening:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();Every feature you add later follows the same three-file pattern: controller, service, module.
Controllers
Controllers handle incoming requests and return responses. The @Controller() decorator sets the route prefix, and method decorators like @Get() and @Post() set the rest:
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
@Controller('users')
export class UsersController {
@Get()
findAll() {
return ['Alice', 'Bob']; // GET /users
}
@Get(':id')
findOne(@Param('id') id: string) {
return `User ${id}`; // GET /users/123
}
@Post()
create(@Body() body: { name: string }) {
return `Created ${body.name}`; // POST /users
}
}Keep controllers thin. They should parse the request, call a service, and return the result. Business logic belongs in providers.
Providers and Services
Providers hold the business logic. A service is a provider decorated with @Injectable(), which lets NestJS manage its lifecycle and inject it where needed:
import { Injectable, NotFoundException } from '@nestjs/common';
export interface User {
id: number;
name: string;
}
@Injectable()
export class UsersService {
private users: User[] = [{ id: 1, name: 'Alice' }];
findAll(): User[] {
return this.users;
}
findOne(id: number): User {
const user = this.users.find((u) => u.id === id);
if (!user) {
throw new NotFoundException(`User ${id} does not exist`);
}
return user;
}
create(name: string): User {
const user = { id: Date.now(), name };
this.users.push(user);
return user;
}
}Throwing NotFoundException here returns a clean 404 JSON response automatically. NestJS ships HTTP exceptions for the common status codes, so you rarely build error responses by hand.
Modules
Modules group related controllers and providers into one unit. Each feature gets its own module, and the root AppModule imports them all:
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // makes it usable in other modules
})
export class UsersModule {}import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
})
export class AppModule {}Generate the trio with one command instead of writing the boilerplate:
nest generate resource usersThis scaffolds the controller, service, module, DTOs, and test files, and wires the module into AppModule.
Dependency Injection
Dependency injection sounds academic, but the idea is simple: classes receive what they need through the constructor instead of creating it. NestJS reads the constructor, finds the matching provider, and passes it in:
@Controller('users')
export class UsersController {
// NestJS creates UsersService once and injects the same instance here
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
}Because the controller never calls new UsersService(), you can swap the real service for a mock in tests without touching the controller code.
Validation with Pipes
DTOs (Data Transfer Objects) describe the shape of incoming data. Combined with class-validator decorators and the global ValidationPipe, they reject bad requests before your code runs:
import { IsEmail, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(2)
name: string;
@IsEmail()
email: string;
}Turn the pipe on once in main.ts:
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
);
await app.listen(3000);
}With whitelist: true, extra properties get stripped. With forbidNonWhitelisted: true, they fail the request. A POST /users with an invalid email now returns 400 with a clear message, and your service never sees the bad data.
Built-in pipes also handle common conversions. ParseIntPipe turns a route param into a number or throws:
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id); // id is a number here
}Error Handling
NestJS converts thrown HTTP exceptions into JSON responses. For consistent logging or a custom error shape, add an exception filter:
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
message: exception.message,
timestamp: new Date().toISOString(),
});
}
}Register it globally in main.ts with app.useGlobalFilters(new HttpExceptionFilter()) and every HTTP error in your API shares the same format.
Testing
The CLI generates a spec file next to every controller and service. A service test looks like this:
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
describe('UsersService', () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('creates a user', () => {
const user = service.create('Alice');
expect(user.name).toBe('Alice');
expect(service.findAll()).toContain(user);
});
});Because services receive dependencies through the constructor, you can override any provider with a mock via .overrideProvider() in the testing module. Run everything with npm run test.
Best Practices
- One module per feature —
UsersModule,AuthModule,OrdersModule. Import them intoAppModule, not into each other in circles. - Thin controllers, fat services — controllers handle HTTP, services hold logic.
- Validate at the boundary — DTOs plus the global
ValidationPipeso bad data never reaches your services. - Throw HTTP exceptions, don't build responses —
NotFoundException,BadRequestException, and friends keep error handling uniform. - Write the generated tests — the CLI creates the spec files. Fill them in while the logic is fresh.
// ✅ Good - logic in a testable service
@Injectable()
export class OrdersService {
constructor(private readonly paymentsService: PaymentsService) {}
async checkout(cartId: string) {
const cart = await this.carts.find(cartId);
return this.paymentsService.charge(cart.total);
}
}
// ❌ Avoid - database and payment calls inside the controller
@Post('checkout')
async checkout(@Body() body: any) {
const result = await db.query('SELECT ...', [body.cartId]);
// ...
}That covers the foundation: controllers route requests, providers hold logic, modules organize features, and pipes keep bad data out. From here, natural next steps are authentication with guards, database access with TypeORM or Prisma, and configuration with @nestjs/config.
The fastest way to lock this in is to scaffold a small project with nest generate resource and build one CRUD feature end to end. Happy coding! 🚀


