Hello Everyone π
I'm Rajat, and three months ago, I had no idea what NestJS was. I'd built some Express APIs, struggled with TypeScript, and my code was... let's just say "spaghetti" is being generous. π
Today? I'm building production-ready backends that my future self won't hate me for. And I want to show you exactly how I got here - mistakes, confusion, and all.
This isn't your typical "here's the code, figure it out" tutorial. This is "here's what I tried, why it failed, what actually worked, and how to avoid the 47 hours I wasted debugging."
π€ Why I Switched to NestJS (The Honest Story)
My Express.js nightmare:
// This was my "organized" Express code π€¦
app.post('/users', async (req, res) => {
// Validation? What's that?
const user = await db.users.create(req.body);
res.json(user);
});
// Another file, similar code copy-pasted
app.post('/posts', async (req, res) => {
// Same validation logic copy-pasted (don't judge)
const post = await db.posts.create(req.body);
res.json(post);
});
// Where's the auth? Error handling? Type safety?
// Past me: "I'll add that later" (narrator: he didn't)
Problems I faced:
- Spent 2 hours debugging typos (
user.naminstead ofuser.name) - Copy-pasted auth middleware 47 times (yes, I counted)
- No idea how to structure larger projects
- Tests? What tests? π°
Enter NestJS:
// Same functionality, but look at this beauty!
@Controller('users') // Routes automatically organized
export class UsersController {
@Post() // Clear HTTP method
@UseGuards(JwtAuthGuard) // Auth in one line!
create(@Body() createUserDto: CreateUserDto) { // Automatic validation!
return this.usersService.create(createUserDto);
}
}
What clicked for me:
- TypeScript everywhere - my IDE now catches bugs before I even run the code
- Dependency Injection - sounds fancy, but it's just "smart code sharing"
- Decorators - those @ things make everything magical
- Structure - NestJS forces good habits (my code actually looks professional now!)
π What This Guide Covers
Full transparency:
- I'm NOT a senior dev (just a student who finally "got it")
- This took me 3 months of evenings and weekends
- I made every mistake possible (and documented them!)
- If I can learn this, you definitely can
What you'll learn:
- β NestJS from absolute scratch
- β Prisma ORM (way better than writing SQL by hand)
- β Real authentication (JWT, not "TODO: add auth")
- β Every line of code explained (like you're 5)
- β Common mistakes I made (with fixes!)
- β Production deployment (not just localhost)
Time investment: One focused weekend (or 3-4 evenings)
Difficulty: Beginner-friendly (I explain EVERYTHING)
Cost: $0 (all free tools!)
Note: I'm writing this as I would've wanted it 3 months ago. If something's confusing, that's on me - leave a comment!
β‘ Quick Start (If You're Impatient)
Trust me, I get it. You want to see it work FIRST, then understand later. Here's the speedrun:
# 1. Install NestJS CLI globally
npm install -g @nestjs/cli
# 2. Create new project
nest new my-awesome-api
# Choose npm (or your preference)
cd my-awesome-api
# 3. Install Prisma
npm install @prisma/client
npm install -D prisma
# 4. Initialize Prisma
npx prisma init
# 5. Start dev server
npm run start:dev
# π Visit http://localhost:3000
Did it work? Awesome! Now let's understand WHY it works and build something real.
Got errors? Don't panic! Check the Troubleshooting section (I've probably hit that error too).
π οΈ The Setup (With All My Mistakes)
Step 1: Installing Node.js (Yes, Really!)
Mistake I made: Used an ancient Node version (v14) and spent 2 days wondering why nothing worked.
The right way:
# Check your Node version
node --version # Should be v18 or higher!
# If it's older, download from https://nodejs.org/
# (Get the LTS version, not the "Current" one)
# Verify installation
node --version # v18.x.x or higher β
npm --version # 8.x.x or higher β
Why this matters: NestJS uses modern JavaScript features. Old Node = weird errors that Google can't help with.
Step 2: Creating Your First NestJS Project
Mistake I made: Tried to manually set up everything. Wasted 4 hours. Don't be like me.
The right way:
# Install NestJS CLI (this is your best friend)
npm install -g @nestjs/cli
# Create new project
nest new nestjs-prisma-backend
# It'll ask: Which package manager?
# Choose npm (unless you know what you're doing with yarn/pnpm)
# Move into the project
cd nestjs-prisma-backend
# Open in your editor
code . # If you have VS Code
# Start the dev server
npm run start:dev
What just happened?
NestJS CLI created a fully working backend with:
- TypeScript configured β
- Testing setup β
- Hot reload β
- Proper folder structure β
- Sample code to learn from β
Check if it worked:
Open http://localhost:3000 in your browser.
See "Hello World!"? You just created a backend! π
Don't see it? Check if:
- Port 3000 is already in use (close other servers)
- You ran
npm run start:dev(not justnpm start) - You're in the project folder
Step 3: Understanding the Magic Folder Structure
The moment I "got it": When I stopped thinking of it as random folders and started seeing the pattern.
src/
βββ app.controller.ts // Handles HTTP requests
βββ app.service.ts // Business logic lives here
βββ app.module.ts // Connects everything
βββ main.ts // Starts the app
Let me break this down in plain English:
main.ts - The ignition
// This file STARTS everything
async function bootstrap() {
const app = await NestFactory.create(AppModule); // Create the app
await app.listen(3000); // Start listening on port 3000
}
bootstrap(); // GO!
app.module.ts - The organizer
@Module({ // This decorator makes it a "module"
imports: [], // Other modules we need (we'll add stuff here)
controllers: [AppController], // Which controllers to use
providers: [AppService], // Which services to use
})
export class AppModule {}
app.controller.ts - The receptionist
@Controller() // Handles incoming requests
export class AppController {
constructor(private appService: AppService) {} // Get the service
@Get() // When someone visits GET /
getHello(): string {
return this.appService.getHello(); // Ask service for data
}
}
app.service.ts - The worker
@Injectable() // Can be injected into controllers
export class AppService {
getHello(): string {
return 'Hello World!'; // Actual business logic
}
}
The pattern I finally understood:
- Request comes in β Controller catches it
- Controller asks β Service does the work
- Service returns β Controller sends response
- Module connects β Everything together
This separation is why NestJS feels professional - each file has ONE job!
Step 4: Installing Prisma (The Database Magic)
What I thought: "Do I really need an ORM? Can't I just write SQL?"
What I learned: Prisma is not about avoiding SQL. It's about not shooting yourself in the foot.
Install Prisma:
# Install Prisma Client (what your code uses)
npm install @prisma/client
# Install Prisma CLI (what YOU use)
npm install -D prisma
# Initialize Prisma in your project
npx prisma init
What this created:
prisma/
βββ schema.prisma // Your database design goes here
.env // Secret stuff (database passwords, etc)
Mistake I made: Committed .env to GitHub with my database password. Don't do this!
The right way:
# Add to .gitignore (NestJS already does this, but double-check!)
echo ".env" >> .gitignore
Step 5: Setting Up the Database
My first attempt: Installed PostgreSQL, got confused by all the settings, gave up.
What actually worked: Docker makes this stupidly easy.
Option A: Docker (Recommended - SO much easier)
Create docker-compose.yml in your project root:
version: '3.8' # Docker Compose version
services: # What containers to run
postgres: # Name of this service
image: postgres:15-alpine # Use PostgreSQL v15 (lightweight)
environment: # Database settings
POSTGRES_USER: postgres # Database username
POSTGRES_PASSWORD: postgres # Database password (CHANGE IN PRODUCTION!)
POSTGRES_DB: nestjs_db # Database name
ports: # Make it accessible
- '5432:5432' # Host:Container (default Postgres port)
volumes: # Persist data (don't lose it when container stops)
- postgres_data:/var/lib/postgresql/data
volumes: # Define the volume
postgres_data: # Name it
Start it:
# Start PostgreSQL
docker-compose up -d # -d means "in background"
# Check if it's running
docker-compose ps
# See logs if something's wrong
docker-compose logs postgres
Option B: Local PostgreSQL (If you prefer)
Download from https://www.postgresql.org/download/
After install:
# Create database
createdb nestjs_db
# Or using psql
psql postgres
CREATE DATABASE nestjs_db;
\q
Step 6: Connecting to the Database
Edit .env:
# DATABASE_URL is what Prisma looks for
# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/nestjs_db?schema=public"
# β β β β β β
# username password host port db name schema
# Application settings
PORT=3000 # What port your API runs on
NODE_ENV=development # Development mode (shows detailed errors)
Mistake I made: Forgot to change localhost to postgres when using Docker Compose.
When using Docker: Use service name instead of localhost:
# For Docker Compose, use the service name!
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/nestjs_db?schema=public"
# β
# service name from docker-compose.yml
ποΈ Prisma Schema - Designing Your Database
The lightbulb moment: Prisma schema is just describing your database in code.
Edit prisma/schema.prisma:
// This tells Prisma what database type we're using
generator client {
provider = "prisma-client-js" // Generate JavaScript/TypeScript client
}
// Connection details (reads from .env)
datasource db {
provider = "postgresql" // We're using PostgreSQL
url = env("DATABASE_URL") // Get URL from .env file
}
// Now let's define our first table!
model User {
// Fields (columns in database)
id String @id @default(uuid()) // Primary key, auto-generate UUID
email String @unique // Must be unique across all users
name String? // ? means optional (nullable)
password String // Required field
role Role @default(USER) // Default role is USER
// Relationships
posts Post[] // One user can have many posts
// Timestamps (track when created/updated)
createdAt DateTime @default(now()) // Auto-set on creation
updatedAt DateTime @updatedAt // Auto-update on changes
@@map("users") // Table name in database will be "users"
}
// Second table - Posts
model Post {
id String @id @default(uuid()) // Primary key
title String // Post title (required)
content String? // Post content (optional)
published Boolean @default(false) // Draft by default
// Foreign key relationship
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
// Connect to User table β using this field β
// If user deleted, delete their posts too β
authorId String // This actually stores the user's ID
tags Tag[] // Many-to-many relationship
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("posts") // Table name
@@index([authorId]) // Index for faster queries by author
}
// Third table - Tags
model Tag {
id String @id @default(uuid())
name String @unique // Tag names must be unique
posts Post[] // Many-to-many with posts
@@map("tags")
}
// Enum for user roles
enum Role {
USER // Regular user
ADMIN // Admin user
MODERATOR // Moderator
}
Understanding relationships:
// One-to-Many: One user β Many posts
model User {
posts Post[] // User side: array of posts
}
model Post {
author User @relation(fields: [authorId], references: [id])
authorId String // Post side: single user ID
}
// Translation to SQL:
// users table: id | email | name | ...
// posts table: id | title | authorId | ... (authorId references users.id)
Create the migration:
# This creates SQL files and updates database
npx prisma migrate dev --name init
# What happens:
# 1. Prisma reads schema.prisma
# 2. Generates SQL migration file
# 3. Creates tables in database
# 4. Generates TypeScript types
Check if it worked:
# Open Prisma Studio (visual database browser)
npx prisma studio
# Opens http://localhost:5555
# You can see your empty tables!
π Creating Prisma Service (The Smart Way)
Why we need this: We want ONE connection to the database that all our code shares.
Create src/prisma/prisma.service.ts:
import {
Injectable, // Makes this class injectable
OnModuleInit, // Hook: runs when module starts
OnModuleDestroy // Hook: runs when module stops
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable() // Now we can inject this into other classes
export class PrismaService
extends PrismaClient // Get all Prisma methods
implements OnModuleInit, OnModuleDestroy { // Implement lifecycle hooks
// When module starts, connect to database
async onModuleInit() {
await this.$connect(); // Open database connection
console.log('β
Database connected'); // Confirmation
}
// When module stops, disconnect from database
async onModuleDestroy() {
await this.$disconnect(); // Close database connection
console.log('β Database disconnected');
}
// Helper method for database transactions
// (multiple operations that all succeed or all fail together)
async executeTransaction<T>(
fn: (prisma: PrismaClient) => Promise<T>
): Promise<T> {
return this.$transaction(fn); // Prisma handles transaction
}
}
Why this pattern?
- Single connection - don't create 100 database connections
- Automatic cleanup - disconnects when app stops
- Transaction support - built-in for complex operations
Create src/prisma/prisma.module.ts:
import { Module, Global } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global() // Make this module available EVERYWHERE (no need to import)
@Module({
providers: [PrismaService], // Register the service
exports: [PrismaService], // Make it available to other modules
})
export class PrismaModule {}
Register it in app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; // For .env support
import { PrismaModule } from './prisma/prisma.module'; // Our Prisma module
@Module({
imports: [
ConfigModule.forRoot({ // Load .env file
isGlobal: true, // Make config available everywhere
}),
PrismaModule, // Add Prisma module
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Test it:
npm run start:dev
# You should see:
# β
Database connected
ποΈ Building Your First Real API
The plan: Create a complete Users API with Create, Read, Update, Delete (CRUD).
Generate the Resource
# NestJS CLI magic - generates everything we need!
nest generate resource users
# Questions it asks:
? What transport layer do you use? REST API # β Choose this
? Would you like to generate CRUD entry points? Yes # β Choose this
# What it creates:
# users/
# βββ dto/
# β βββ create-user.dto.ts
# β βββ update-user.dto.ts
# βββ entities/
# β βββ user.entity.ts
# βββ users.controller.ts
# βββ users.module.ts
# βββ users.service.ts
Mistake I made: Thought I had to create all these files manually. Use the CLI!
Understanding DTOs (Data Transfer Objects)
What confused me: "Why not just use the Prisma types?"
The answer: DTOs are about API security and validation.
Edit src/users/dto/create-user.dto.ts:
import {
IsEmail, // Validates email format
IsNotEmpty, // Can't be empty
IsString, // Must be string
MinLength, // Minimum length
IsOptional, // Field is optional
IsEnum // Must be one of enum values
} from 'class-validator';
import { Role } from '@prisma/client';
export class CreateUserDto {
// Email validation
@IsEmail({}, { message: 'Please provide a valid email' }) // Custom error message
@IsNotEmpty({ message: 'Email is required' })
email: string;
// Name validation
@IsString()
@IsNotEmpty({ message: 'Name is required' })
name: string;
// Password validation
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string;
// Optional role (defaults to USER in database)
@IsOptional() // Not required in request
@IsEnum(Role, { message: 'Invalid role' })
role?: Role;
}
// Example valid request:
// {
// "email": "john@example.com",
// "name": "John Doe",
// "password": "securepass123"
// }
// Example INVALID request (will be rejected):
// {
// "email": "notanemail", // β Invalid email format
// "password": "short" // β Too short
// }
Why this is powerful:
- Automatic validation - bad requests get rejected before hitting your code
- Type safety - TypeScript knows what fields exist
- Documentation - other devs know what's required
Edit src/users/dto/update-user.dto.ts:
import { PartialType } from '@nestjs/mapped-types'; // Makes all fields optional
import { CreateUserDto } from './create-user.dto';
// PartialType makes a copy of CreateUserDto with all fields optional
// This is perfect for updates (you might only want to change one field)
export class UpdateUserDto extends PartialType(CreateUserDto) {
// Inherit all validation from CreateUserDto
// But everything is optional now!
}
// Example valid update:
// { "name": "Jane Doe" } // Only updating name β
// { "email": "new@example.com", "name": "Jane" } // Multiple fields β
Implementing the Service (Where the Magic Happens)
Edit src/users/users.service.ts:
import {
Injectable, // Makes it injectable
NotFoundException, // 404 error
ConflictException // 409 error (duplicate)
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import * as bcrypt from 'bcrypt'; // For password hashing
@Injectable()
export class UsersService {
// Dependency Injection: get PrismaService automatically
constructor(private prisma: PrismaService) {}
// CREATE: Add new user
async create(createUserDto: CreateUserDto) {
// Step 1: Check if email already exists
const existingUser = await this.prisma.user.findUnique({
where: { email: createUserDto.email }, // Search by email
});
if (existingUser) {
// Email taken? Throw error (stops execution)
throw new ConflictException('User with this email already exists');
}
// Step 2: Hash the password (NEVER store plain passwords!)
const hashedPassword = await bcrypt.hash(
createUserDto.password, // Plain password
10 // Salt rounds (higher = more secure but slower)
);
// Step 3: Create user in database
const user = await this.prisma.user.create({
data: {
...createUserDto, // Spread all fields
password: hashedPassword, // Replace with hashed version
},
select: { // Only return these fields (don't expose password!)
id: true,
email: true,
name: true,
role: true,
createdAt: true,
updatedAt: true,
// password: false // Explicitly exclude password
},
});
return user; // Return created user
}
// READ ALL: Get all users
async findAll() {
return this.prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
updatedAt: true,
// No password! Security first.
},
});
}
// READ ONE: Get specific user
async findOne(id: string) {
const user = await this.prisma.user.findUnique({
where: { id }, // Search by ID
select: {
id: true,
email: true,
name: true,
role: true,
posts: { // Include related posts
select: {
id: true,
title: true,
published: true,
createdAt: true,
},
},
createdAt: true,
updatedAt: true,
},
});
if (!user) {
// Not found? Throw 404 error
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
// Helper method for authentication (we'll use this later)
async findByEmail(email: string) {
return this.prisma.user.findUnique({
where: { email },
// Include password here (needed for login verification)
});
}
// UPDATE: Modify existing user
async update(id: string, updateUserDto: UpdateUserDto) {
// Step 1: Check if user exists (throws 404 if not)
await this.findOne(id);
// Step 2: If updating password, hash it
if (updateUserDto.password) {
updateUserDto.password = await bcrypt.hash(updateUserDto.password, 10);
}
// Step 3: Update in database
const user = await this.prisma.user.update({
where: { id },
data: updateUserDto, // Only update provided fields
select: { // Again, no password
id: true,
email: true,
name: true,
role: true,
createdAt: true,
updatedAt: true,
},
});
return user;
}
// DELETE: Remove user
async remove(id: string) {
// Step 1: Check if exists
await this.findOne(id);
// Step 2: Delete (Cascade will delete related posts too)
await this.prisma.user.delete({
where: { id },
});
return { message: 'User deleted successfully' };
}
}
Install bcrypt:
npm install bcrypt
npm install -D @types/bcrypt
Common mistake I made: Forgot to hash passwords. NEVER store plain passwords!
Creating the Controller (The API Routes)
Edit src/users/users.controller.ts:
import {
Controller, // Marks this as a controller
Get, // HTTP GET decorator
Post, // HTTP POST decorator
Body, // Get request body
Patch, // HTTP PATCH decorator
Param, // Get URL parameter
Delete, // HTTP DELETE decorator
HttpCode, // Set response code
HttpStatus, // HTTP status codes
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users') // Base route: /users
export class UsersController {
// Inject UsersService
constructor(private readonly usersService: UsersService) {}
// POST /users - Create new user
@Post()
@HttpCode(HttpStatus.CREATED) // Return 201 instead of 200
create(@Body() createUserDto: CreateUserDto) {
// @Body() automatically:
// 1. Parses JSON from request
// 2. Validates against CreateUserDto
// 3. Throws error if validation fails
return this.usersService.create(createUserDto);
}
// GET /users - Get all users
@Get()
findAll() {
// Returns array of users
return this.usersService.findAll();
}
// GET /users/:id - Get specific user
@Get(':id')
findOne(@Param('id') id: string) {
// @Param('id') extracts :id from URL
// Example: GET /users/abc-123 β id = 'abc-123'
return this.usersService.findOne(id);
}
// PATCH /users/:id - Update user
@Patch(':id')
update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto
) {
// Both URL parameter and request body
return this.usersService.update(id, updateUserDto);
}
// DELETE /users/:id - Delete user
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT) // Return 204 (no content)
remove(@Param('id') id: string) {
return this.usersService.remove(id);
}
}
URL examples:
POST /users β Create user
GET /users β Get all users
GET /users/123 β Get user with ID 123
PATCH /users/123 β Update user 123
DELETE /users/123 β Delete user 123
Enable Global Validation
Edit src/main.ts:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; // For DTO validation
import { AppModule } from './app.module';
async function bootstrap() {
// Create NestJS application
const app = await NestFactory.create(AppModule);
// Enable GLOBAL validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip properties that don't have decorators
// Example: If DTO has {email, name} but request has {email, name, hack}
// β 'hack' is automatically removed
forbidNonWhitelisted: true, // Throw error if unknown properties exist
// Instead of silently removing, reject the request
transform: true, // Auto-transform payloads to DTO instances
// Converts plain JavaScript objects to class instances
}),
);
// Set global prefix (all routes start with /api/v1)
app.setGlobalPrefix('api/v1');
// Now routes are: /api/v1/users, /api/v1/auth, etc.
// Start listening
const port = process.env.PORT || 3000;
await app.listen(port);
console.log(`π Server running on: http://localhost:${port}`);
console.log(`π API routes start with: /api/v1`);
}
bootstrap();
Test Your API!
Start the server:
npm run start:dev
Using curl:
# Create a user
curl -X POST http://localhost:3000/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"name": "John Doe",
"password": "securepass123"
}'
# Get all users
curl http://localhost:3000/api/v1/users
# Get specific user (replace {id} with actual ID from creation)
curl http://localhost:3000/api/v1/users/{id}
# Update user
curl -X PATCH http://localhost:3000/api/v1/users/{id} \
-H "Content-Type: application/json" \
-d '{"name": "Jane Doe"}'
# Delete user
curl -X DELETE http://localhost:3000/api/v1/users/{id}
Using Postman/Thunder Client:
- Create new request
- Set method to POST
- URL:
http://localhost:3000/api/v1/users - Body β raw β JSON:
{
"email": "test@example.com",
"name": "Test User",
"password": "password123"
}
- Send!
Test validation:
# Try invalid email
curl -X POST http://localhost:3000/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"email": "notanemail",
"name": "John",
"password": "short"
}'
# You'll get validation errors! β
π Authentication (The Right Way)
Confession: My first auth system was if (password === 'admin123'). Don't laugh, we've all been there. π
Now we're doing it properly with JWT (JSON Web Tokens).
Generate Auth Module
nest generate module auth
nest generate service auth
nest generate controller auth
Install Dependencies
# JWT and Passport (authentication library)
npm install @nestjs/jwt @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwt
Understanding JWT (In Plain English)
JWT is like a concert wristband:
- You show your ticket (login with email/password)
- You get a wristband (JWT token)
- Show wristband to get in everywhere (use token for API requests)
- Wristband has expiry date (token expires)
JWT structure:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three parts separated by .:
- Header - Token type and algorithm
- Payload - User data (email, ID, etc.)
- Signature - Verification (ensures not tampered)
Create Auth DTOs
Create src/auth/dto/register.dto.ts:
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class RegisterDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string;
}
Create src/auth/dto/login.dto.ts:
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
password: string;
}
Implement Auth Service
Edit src/auth/auth.service.ts:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; // For creating tokens
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService, // Inject JWT service
private usersService: UsersService, // Inject Users service
) {}
// REGISTER: Create account
async register(registerDto: RegisterDto) {
// Create user (UsersService handles hashing and validation)
const user = await this.usersService.create(registerDto);
// Generate JWT token
const token = this.generateToken(user.id, user.email);
return {
user, // User data (without password)
access_token: token, // JWT token
};
}
// LOGIN: Verify credentials
async login(loginDto: LoginDto) {
// Step 1: Find user by email
const user = await this.usersService.findByEmail(loginDto.email);
if (!user) {
// User doesn't exist
throw new UnauthorizedException('Invalid credentials');
// Generic message (don't reveal if email exists for security)
}
// Step 2: Verify password
const isPasswordValid = await bcrypt.compare(
loginDto.password, // Plain password from request
user.password // Hashed password from database
);
if (!isPasswordValid) {
// Wrong password
throw new UnauthorizedException('Invalid credentials');
}
// Step 3: Generate JWT token
const token = this.generateToken(user.id, user.email);
return {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
// No password!
},
access_token: token,
};
}
// VALIDATE: Check if token is valid
async validateUser(userId: string) {
// Called by JWT strategy on every protected request
return this.usersService.findOne(userId);
}
// PRIVATE: Generate JWT token
private generateToken(userId: string, email: string): string {
// Payload (data stored in token)
const payload = {
sub: userId, // "sub" is JWT standard for user ID
email: email // Include email for convenience
};
// Sign and return token
return this.jwtService.sign(payload);
// Token expires in 7 days (set in module config)
}
}
Create JWT Strategy
Create src/auth/strategies/jwt.strategy.ts:
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; // Passport integration
import { ExtractJwt, Strategy } from 'passport-jwt'; // JWT strategy
import { AuthService } from '../auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({
// Where to find the token in request
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// Looks for: Authorization: Bearer <token>
ignoreExpiration: false, // Reject expired tokens
secretOrKey: process.env.JWT_SECRET, // Secret key from .env
// This MUST match the secret used to sign tokens
});
}
// This runs AFTER token is verified
// Payload = decoded token data
async validate(payload: any) {
// payload.sub = user ID (from generateToken)
const user = await this.authService.validateUser(payload.sub);
if (!user) {
// User doesn't exist anymore (maybe deleted)
throw new UnauthorizedException();
}
// Return value gets attached to request.user
return user;
}
}
How it works:
- Request comes with
Authorization: Bearer <token> - JWT Strategy extracts and verifies token
- If valid,
validate()runs with decoded payload - User data attached to request
- Your route handler has access to user!
Create JWT Guard
Create src/auth/guards/jwt-auth.guard.ts:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
// This extends Passport's built-in guard
// 'jwt' refers to our JwtStrategy
}
// Usage: @UseGuards(JwtAuthGuard)
// Automatically runs JwtStrategy for that route
Create User Decorator (Quality of Life)
Create src/common/decorators/current-user.decorator.ts:
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
// Custom decorator to get current user easily
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
// Get HTTP request from context
const request = ctx.switchToHttp().getRequest();
// Return user (attached by JwtStrategy)
return request.user;
},
);
// Usage in controller:
// getProfile(@CurrentUser() user: User) {
// return user; // Already has current user!
// }
Configure Auth Module
Edit src/auth/auth.module.ts:
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; // JWT module
import { PassportModule } from '@nestjs/passport'; // Passport module
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module'; // Need access to users
@Module({
imports: [
UsersModule, // We need UsersService
PassportModule, // Enable Passport
JwtModule.register({ // Configure JWT
secret: process.env.JWT_SECRET, // Secret key for signing tokens
// In production, use a long random string!
// Generate with: openssl rand -base64 32
signOptions: {
expiresIn: process.env.JWT_EXPIRES_IN || '7d' // Token expires in 7 days
// After 7 days, user needs to login again
},
}),
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy, // Register our strategy
],
exports: [AuthService], // Export so other modules can use
})
export class AuthModule {}
Don't forget to add JWT_SECRET to .env:
# JWT Configuration
JWT_SECRET=your-super-secret-key-change-this-in-production-use-openssl-rand-base64-32
JWT_EXPIRES_IN=7d
Create Auth Controller
Edit src/auth/auth.controller.ts:
import {
Controller,
Post,
Body,
Get,
UseGuards,
HttpCode,
HttpStatus
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; // Our guard
import { CurrentUser } from '../common/decorators/current-user.decorator'; // Our decorator
@Controller('auth') // Base route: /auth
export class AuthController {
constructor(private readonly authService: AuthService) {}
// POST /auth/register - Create account
@Post('register')
@HttpCode(HttpStatus.CREATED) // 201 status
register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto);
// Returns: { user: {...}, access_token: "..." }
}
// POST /auth/login - Login
@Post('login')
@HttpCode(HttpStatus.OK) // 200 status
login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
// Returns: { user: {...}, access_token: "..." }
}
// GET /auth/me - Get current user (PROTECTED)
@Get('me')
@UseGuards(JwtAuthGuard) // Requires authentication
getProfile(@CurrentUser() user: any) {
// @CurrentUser() automatically gives us the logged-in user
// No need to decode token manually!
return user;
}
}
Protect User Routes
Edit src/users/users.controller.ts:
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { UsersController } from './users.controller';
@Controller('users')
@UseGuards(JwtAuthGuard) // π ALL routes now require authentication
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// All routes now protected!
// GET /users/profile - Get MY profile
@Get('profile')
getProfile(@CurrentUser() user: any) {
// Returns the currently logged-in user
return user;
}
// ... rest of your routes
}
Protecting specific routes only:
@Controller('users')
export class UsersController {
// Public route (no guard)
@Get()
findAll() {
return this.usersService.findAll();
}
// Protected route
@Get('profile')
@UseGuards(JwtAuthGuard) // π Only this route protected
getProfile(@CurrentUser() user: any) {
return user;
}
}
Test Authentication!
1. Register a new user:
curl -X POST http://localhost:3000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"name": "Test User",
"password": "password123"
}'
# Response:
# {
# "user": {
# "id": "abc-123",
# "email": "test@example.com",
# "name": "Test User",
# "role": "USER"
# },
# "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
# }
2. Login:
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123"
}'
# Copy the access_token from response!
3. Access protected route:
# Without token (will fail)
curl http://localhost:3000/api/v1/auth/me
# With token (works!)
curl http://localhost:3000/api/v1/auth/me \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
4. Try wrong password:
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "wrongpassword"
}'
# Response: 401 Unauthorized β
π What I Learned (The Hard Way)
Mistake #1: Not Using DTOs
What I did:
// Just accepting any data
create(@Body() body: any) {
return this.service.create(body);
}
Why it's bad:
- No validation
- No type safety
- Users can send malicious data
- Debugging nightmares
The fix:
// Proper way with DTO
create(@Body() createUserDto: CreateUserDto) {
return this.service.create(createUserDto);
}
Mistake #2: Exposing Passwords
What I did:
// Returning everything from database
return this.prisma.user.findMany();
Why it's bad:
- Passwords exposed in API responses!
- Even hashed passwords shouldn't be exposed
The fix:
return this.prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
// password: explicitly excluded
},
});
Mistake #3: No Error Handling
What I did:
async findOne(id: string) {
return this.prisma.user.findUnique({ where: { id } });
// Returns null if not found (no error!)
}
Why it's bad:
- Frontend gets
nullinstead of proper error - No way to know if it failed or user doesn't exist
The fix:
async findOne(id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
Mistake #4: Weak JWT Secret
What I did:
JWT_SECRET=secret
Why it's bad:
- Easy to crack
- Production security disaster
- All your tokens are compromised
The fix:
# Generate strong secret
openssl rand -base64 32
# β xkz9vK/mN8jQ2wR7... (use this!)
Mistake #5: Not Using Environment Variables
What I did:
// Hardcoded in code
secret: 'my-secret-key'
Why it's bad:
- Committed to GitHub (everyone sees it!)
- Can't change without redeploying
- Different values for dev/production impossible
The fix:
// Use environment variables
secret: process.env.JWT_SECRET
π Troubleshooting (Trust Me, I've Seen It All)
"Cannot find module '@prisma/client'"
Fix:
npx prisma generate
"Port 3000 already in use"
Fix:
# Find what's using port 3000
lsof -ti:3000
# Kill it
kill -9 $(lsof -ti:3000)
# Or use different port
PORT=3001 npm run start:dev
"Database connection failed"
Check:
- Is PostgreSQL running?
docker-compose ps - Is DATABASE_URL correct in
.env? - Can you connect with
psql?
Fix:
# Restart database
docker-compose restart postgres
# Check logs
docker-compose logs postgres
"Prisma migration failed"
Fix:
# Reset database (WARNING: deletes all data!)
npx prisma migrate reset
# Or create new migration
npx prisma migrate dev --name fix_issue
"JWT token invalid"
Check:
- Is JWT_SECRET the same everywhere?
- Is token expired?
- Are you sending
Bearer <token>format?
π‘ Best Practices I Follow Now
1. Always Use DTOs
// β
Good
create(@Body() createUserDto: CreateUserDto)
// β Bad
create(@Body() body: any)
2. Never Expose Passwords
// β
Good - explicit select
select: {
id: true,
email: true,
// password intentionally excluded
}
// β Bad - returns everything
return this.prisma.user.findMany();
3. Handle Errors Properly
// β
Good
if (!user) {
throw new NotFoundException('User not found');
}
// β Bad
return null; // Frontend has no idea what happened
4. Use Environment Variables
// β
Good
secret: process.env.JWT_SECRET
// β Bad
secret: 'hardcoded-secret'
5. Validate Everything
// β
Good - validation decorators
@IsEmail()
@MinLength(8)
// β Bad - trust user input
password: string;
π What's Next?
This is Part 1! We covered:
- β Project setup
- β Database with Prisma
- β CRUD operations
- β Authentication with JWT
Coming in Part 2:
- File uploads
- Email sending
- Testing (unit + e2e)
- Deployment to production
- Docker containers
- CI/CD pipeline
Want me to cover anything specific? Drop a comment! I read every single one.
π¬ Let's Connect!
If this guide helped you:
- β€οΈ Give it a like
- π¬ Comment what you learned
- π Share with friends learning backend
- π Bookmark for reference
Questions? I'll answer every comment! We're all learning together. π
Made with β€οΈ, lots of coffee, and way too many "why isn't this working" moments by Rajat
CS Student | Backend Developer | Probably debugging right now
P.S. - If you're stuck, don't give up! I was stuck for weeks on some of these concepts. It clicks eventually, I promise! πͺ
P.P.S. - Part 2 drops next week. Follow so you don't miss it! π
P.P.P.S. - My first NestJS app had 0 error handling and passwords in plain text. We all start somewhere. Keep going! π
Top comments (0)