A comprehensive guide tracking my NestJS learning journey - from basics to advanced concepts.
- Getting Started
- Core Concepts
- Modules & Dependency Injection
- Controllers & Routing
- Providers & Services
- Database Integration (MongoDB)
- Authentication & Authorization
- Validation & DTOs
- Advanced Topics
- Best Practices
NestJS is a progressive Node.js framework for building efficient, reliable, and scalable server-side applications. It uses TypeScript by default and combines elements of:
- OOP (Object-Oriented Programming)
- FP (Functional Programming)
- FRP (Functional Reactive Programming)
# Install NestJS CLI
npm i -g @nestjs/cli
# Create new project
nest new project-name
# Start development server
npm run start:dev- Project Name: cms-demo-api
- NestJS Version: 11.x
- Database: MongoDB (Mongoose)
- Main Dependencies:
- @nestjs/mongoose: Database integration
- @nestjs/config: Environment configuration
- bcrypt: Password hashing
Project Structure:
src/
βββ app.module.ts # Root module
βββ main.ts # Entry point
βββ auth/ # Authentication module
β βββ auth.controller.ts
β βββ auth.service.ts
β βββ auth.module.ts
β βββ dto/
β βββ registerUser.dto.ts
βββ user/ # User module
βββ user.service.ts
βββ user.module.ts
βββ schemas/
βββ user.schema.ts
Request β Controller β Service β Database
Response β Controller β Service β Database
| Decorator | Purpose | Example |
|---|---|---|
@Module() |
Define a module | @Module({ imports: [], providers: [] }) |
@Injectable() |
Mark class as provider | @Injectable() export class UserService |
@Controller() |
Define controller | @Controller('users') |
@Get() |
HTTP GET endpoint | @Get('/:id') |
@Post() |
HTTP POST endpoint | @Post('/register') |
@Body() |
Extract request body | create(@Body() dto: CreateDto) |
@Param() |
Extract URL parameter | findOne(@Param('id') id: string) |
Modules are the fundamental building blocks of a NestJS application. Each module encapsulates related functionality.
Example from my project:
// user.module.ts
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
],
providers: [UserService],
exports: [UserService, MongooseModule],
})
export class UserModule {}β Can Be Injected:
- Services with
@Injectable()decorator - Mongoose models (using
@InjectModel()) - Any provider registered in a module
- Built-in services (ConfigService, Logger, etc.)
β Cannot Be Injected:
- Controllers (they're entry points, not services)
- Plain classes without
@Injectable() - Anything not registered as a provider
Pattern 1: Export Service (when you want to use service methods)
// user.module.ts
@Module({
providers: [UserService],
exports: [UserService], // Export service
})
export class UserModule {}
// auth.service.ts
constructor(private userService: UserService) {} // Inject servicePattern 2: Export MongooseModule (when you need direct model access)
// user.module.ts
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
exports: [MongooseModule], // Export module
})
export class UserModule {}
// auth.service.ts
constructor(@InjectModel(User.name) private userModel: Model<User>) {}Pattern 3: Export Both β (most flexible - my approach)
// user.module.ts
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
providers: [UserService],
exports: [UserService, MongooseModule], // Export both
})
export class UserModule {}My AuthModule using UserModule:
// auth.module.ts
@Module({
imports: [UserModule], // 1. Import the module
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}
// auth.service.ts
@Injectable()
export class AuthService {
constructor(private readonly userService: UserService) {} // 2. Inject the service
registerUser(registerDto: RegisterDto) {
return this.userService.createUser(); // 3. Use the service
}
}- Module Encapsulation: Each module owns its providers and schemas
- Export to Share: Only exported providers can be used by other modules
- Import Before Inject: Always import module before injecting its providers
- Avoid Direct Schema Registration: Don't register schemas from other modules directly (breaks encapsulation)
Controllers handle incoming HTTP requests and return responses to the client.
@Controller('auth') // Base route: /auth
export class AuthController {
@Post('register') // Full route: POST /auth/register
register(@Body() dto: RegisterDto) {
return this.authService.registerUser(dto);
}
@Get('profile/:id') // Full route: GET /auth/profile/:id
getProfile(@Param('id') id: string) {
return this.authService.getProfile(id);
}
}@Post('example')
example(
@Body() body: any, // Request body
@Param('id') id: string, // URL parameter
@Query('search') search: string, // Query string
@Headers() headers: any, // Request headers
@Req() request: Request, // Full request object
) {}Providers are classes with the @Injectable() decorator. They can:
- Contain business logic
- Be injected as dependencies
- Access databases, external APIs, etc.
@Injectable()
export class UserService {
constructor(
@InjectModel(User.name) private userModel: Model<User>
) {}
async createUser(userData: RegisterDto) {
const newUser = new this.userModel(userData);
return await newUser.save();
}
async findByEmail(email: string) {
return await this.userModel.findOne({ email });
}
}1. Install dependencies:
npm install @nestjs/mongoose mongoose
npm install @nestjs/config2. Create .env file in project root:
# .env
MONGODB_URL=mongodb://localhost:27017/cms-demo
PORT=30003. Connect in root module (IMPORTANT: Order matters!):
// app.module.ts
import { ConfigModule, ConfigService } from '@nestjs/config';
@Module({
imports: [
// 1. Load ConfigModule FIRST with isGlobal option
ConfigModule.forRoot({
isGlobal: true, // Makes ConfigService available everywhere
envFilePath: '.env',
}),
// 2. Use forRootAsync to access environment variables
MongooseModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
uri: configService.get<string>('MONGODB_URL'),
}),
inject: [ConfigService],
}),
// 3. Other modules
AuthModule,
UserModule,
],
})
export class AppModule {}β Common Mistake (What I fixed):
// DON'T do this - process.env is not loaded yet!
@Module({
imports: [
MongooseModule.forRoot(process.env.MONGODB_URL),
ConfigModule.forRoot(),
],
})forRoot()- Synchronous, runs immediately (env vars might not be loaded)forRootAsync()- Asynchronous, waits for ConfigService to load env vars- Always use
forRootAsync()when using environment variables!
My User Schema:
@Schema({
timestamps: true, // Adds createdAt & updatedAt
collection: 'users', // Explicit collection name
})
export class User {
@Prop({
type: String,
required: true,
maxlength: [30, 'First name must be less than 30 characters'],
})
fname: string;
@Prop({
type: String,
required: true,
unique: true,
lowercase: true,
})
email: string;
@Prop({ type: String, required: true })
password: string;
}
export const UserSchema = SchemaFactory.createForClass(User);
export type UserDocument = User & Document;Registering in Module:
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
],
})Injecting in Service:
constructor(
@InjectModel(User.name) private userModel: Model<User>
) {}// Create
const user = new this.userModel(data);
await user.save();
// Find one
await this.userModel.findOne({ email });
await this.userModel.findById(id);
// Find many
await this.userModel.find({ status: 'active' });
// Update
await this.userModel.findByIdAndUpdate(id, data, { new: true });
// Delete
await this.userModel.findByIdAndDelete(id);- JWT (JSON Web Tokens)
- Passport.js integration
- Guards and authentication guards
- Password hashing with bcrypt
- Role-based access control (RBAC)
AuthService tasks:
- Check if email already exists
- Hash the password using bcrypt
- Store user in database
- Generate JWT token
- Send token in response
DTOs (Data Transfer Objects) define the shape of data for API requests.
export class RegisterDto {
fname: string;
lname: string;
email: string;
password: string;
}- Class-validator package
- Validation decorators (@IsEmail, @MinLength, etc.)
- Global validation pipes
- Custom validators
- Middleware: Request processing before controllers
- Guards: Authorization and authentication
- Interceptors: Transform responses, add logging
- Pipes: Transform and validate input data
- Exception Filters: Custom error handling
- Testing: Unit tests and E2E tests
- Swagger/OpenAPI: API documentation
- WebSockets: Real-time communication
- Microservices: Distributed architecture
- GraphQL: Alternative to REST
β DO:
- Keep modules focused on a single feature
- Export only what other modules need
- Use barrel exports (index.ts)
β DON'T:
- Create one giant module with everything
- Directly register schemas from other modules
- Export controllers
β DO:
// Use constructor injection
constructor(private readonly userService: UserService) {}β DON'T:
// Don't create instances manually
const userService = new UserService();β DO (Recommended):
// Register schema in its own module
// Export MongooseModule to share
@Module({
imports: [MongooseModule.forFeature([{ name: User.name, schema: UserSchema }])],
exports: [MongooseModule],
})β AVOID (Not recommended but works):
// Registering schemas from other modules directly
@Module({
imports: [
MongooseModule.forFeature([
{ name: User.name, schema: UserSchema }, // From another module
{ name: Meeting.name, schema: MeetingSchema }, // From another module
])
],
})- Controllers:
*.controller.ts - Services:
*.service.ts - Modules:
*.module.ts - DTOs:
*.dto.ts - Schemas:
*.schema.ts - Interfaces:
*.interface.ts
- NestJS Crash Course
- NestJS + MongoDB Tutorial
- Authentication with JWT
- CMS Demo API (current)
- Blog API
- E-commerce API
- Understanding NestJS architecture
- Creating modules
- Creating controllers
- Creating services
- Dependency injection basics
- Setting up environment variables
- Using ConfigModule
- Understanding ConfigService
- forRoot vs forRootAsync patterns
- Making ConfigModule global
- MongoDB connection setup
- Creating schemas with Mongoose
- Registering models in modules
- Injecting models in services
- Understanding module exports for schemas
- Fixing database connection issues
- Importing modules
- Exporting providers
- Understanding what can be injected
- Different patterns of sharing resources
- Password hashing with bcrypt
- JWT token generation
- JWT validation
- Auth guards
- Login endpoint
- Protected routes
- Installing class-validator
- Creating DTOs with validation
- Global validation pipe
- Custom validators
- Middleware
- Guards
- Interceptors
- Pipes
- Exception filtersuserModel
- Testing
Topic: Module Exports & Dependency Injection
Today I learned about:
- What can and cannot be injected in NestJS
- Two main patterns for sharing resources:
- Exporting services (for using service methods)
- Exporting MongooseModule (for direct model access)
- Why exporting both is the most flexible approach
- The difference between importing a module vs directly registering schemas
- Best practices favor module encapsulation over direct schema registration
Key Realization:
My current project setup with UserModule exporting both UserService and MongooseModule follows NestJS best practices and provides maximum flexibility for other modules to use User resources either through the service or direct model access.
Topic: Environment Variables & ConfigModule
Fixed a critical bug today!
Problem: Application crashed with MongooseError: The 'uri' parameter must be a string, got "undefined"
Root Cause:
- No
.envfile existed in the project ConfigModule.forRoot()was loaded AFTER trying to accessprocess.env.MONGODB_URL- Used synchronous
MongooseModule.forRoot()instead of async version
Solution:
- β
Created
.envfile withMONGODB_URL - β
Moved
ConfigModule.forRoot()to the TOP of imports array withisGlobal: true - β
Changed to
MongooseModule.forRootAsync()withuseFactorypattern - β
Injected
ConfigServiceto properly access environment variables
Key Takeaway:
- Environment variables are loaded by
ConfigModule.forRoot()at runtime - Always use
forRootAsync()when you need to access async dependencies like ConfigService - The order of module imports matters!
isGlobal: truemakes ConfigService available everywhere without re-importing
- Complete user registration with password hashing
- Implement JWT authentication
- Add validation to DTOs
- Create login endpoint
- Protect routes with guards
- Add error handling
- How do guards work internally?
- When should I use interceptors vs middleware?
- How to structure a large-scale NestJS application?
- Best practices for testing NestJS applications?
- How to implement refresh tokens?
Last Updated: December 19, 2025
Keep learning, keep building! π