DEV Community

Cover image for From Beginner to Pro: Setting Up a TypeScript NestJS Backend with Prisma
Rajat Parihar
Rajat Parihar

Posted on • Edited on

From Beginner to Pro: Setting Up a TypeScript NestJS Backend with Prisma

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)
Enter fullscreen mode Exit fullscreen mode

Problems I faced:

  • Spent 2 hours debugging typos (user.nam instead of user.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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 βœ…
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 just npm 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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

app.service.ts - The worker

@Injectable()  // Can be injected into controllers
export class AppService {
  getHello(): string {
    return 'Hello World!';  // Actual business logic
  }
}
Enter fullscreen mode Exit fullscreen mode

The pattern I finally understood:

  1. Request comes in β†’ Controller catches it
  2. Controller asks β†’ Service does the work
  3. Service returns β†’ Controller sends response
  4. 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
Enter fullscreen mode Exit fullscreen mode

What this created:

prisma/
└── schema.prisma    // Your database design goes here

.env                 // Secret stuff (database passwords, etc)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

πŸ—„οΈ 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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check if it worked:

# Open Prisma Studio (visual database browser)
npx prisma studio

# Opens http://localhost:5555
# You can see your empty tables!
Enter fullscreen mode Exit fullscreen mode

πŸ”Œ 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

Test it:

npm run start:dev

# You should see:
# βœ… Database connected
Enter fullscreen mode Exit fullscreen mode

πŸ—οΈ 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
Enter fullscreen mode Exit fullscreen mode

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
// }
Enter fullscreen mode Exit fullscreen mode

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 βœ…
Enter fullscreen mode Exit fullscreen mode

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' };
  }
}
Enter fullscreen mode Exit fullscreen mode

Install bcrypt:

npm install bcrypt
npm install -D @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Test Your API!

Start the server:

npm run start:dev
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

Using Postman/Thunder Client:

  1. Create new request
  2. Set method to POST
  3. URL: http://localhost:3000/api/v1/users
  4. Body β†’ raw β†’ JSON:
{
  "email": "test@example.com",
  "name": "Test User",
  "password": "password123"
}
Enter fullscreen mode Exit fullscreen mode
  1. 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! βœ…
Enter fullscreen mode Exit fullscreen mode

πŸ” 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
Enter fullscreen mode Exit fullscreen mode

Install Dependencies

# JWT and Passport (authentication library)
npm install @nestjs/jwt @nestjs/passport passport passport-jwt
npm install -D @types/passport-jwt
Enter fullscreen mode Exit fullscreen mode

Understanding JWT (In Plain English)

JWT is like a concert wristband:

  1. You show your ticket (login with email/password)
  2. You get a wristband (JWT token)
  3. Show wristband to get in everywhere (use token for API requests)
  4. Wristband has expiry date (token expires)

JWT structure:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

Three parts separated by .:

  1. Header - Token type and algorithm
  2. Payload - User data (email, ID, etc.)
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  1. Request comes with Authorization: Bearer <token>
  2. JWT Strategy extracts and verifies token
  3. If valid, validate() runs with decoded payload
  4. User data attached to request
  5. 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
Enter fullscreen mode Exit fullscreen mode

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!
// }
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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..."
# }
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 βœ…
Enter fullscreen mode Exit fullscreen mode

πŸŽ“ 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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Mistake #2: Exposing Passwords

What I did:

// Returning everything from database
return this.prisma.user.findMany();
Enter fullscreen mode Exit fullscreen mode

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
  },
});
Enter fullscreen mode Exit fullscreen mode

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!)
}
Enter fullscreen mode Exit fullscreen mode

Why it's bad:

  • Frontend gets null instead 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;
}
Enter fullscreen mode Exit fullscreen mode

Mistake #4: Weak JWT Secret

What I did:

JWT_SECRET=secret
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

Mistake #5: Not Using Environment Variables

What I did:

// Hardcoded in code
secret: 'my-secret-key'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

πŸ› Troubleshooting (Trust Me, I've Seen It All)

"Cannot find module '@prisma/client'"

Fix:

npx prisma generate
Enter fullscreen mode Exit fullscreen mode

"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
Enter fullscreen mode Exit fullscreen mode

"Database connection failed"

Check:

  1. Is PostgreSQL running? docker-compose ps
  2. Is DATABASE_URL correct in .env?
  3. Can you connect with psql?

Fix:

# Restart database
docker-compose restart postgres

# Check logs
docker-compose logs postgres
Enter fullscreen mode Exit fullscreen mode

"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
Enter fullscreen mode Exit fullscreen mode

"JWT token invalid"

Check:

  1. Is JWT_SECRET the same everywhere?
  2. Is token expired?
  3. 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)
Enter fullscreen mode Exit fullscreen mode

2. Never Expose Passwords

// βœ… Good - explicit select
select: {
  id: true,
  email: true,
  // password intentionally excluded
}

// ❌ Bad - returns everything
return this.prisma.user.findMany();
Enter fullscreen mode Exit fullscreen mode

3. Handle Errors Properly

// βœ… Good
if (!user) {
  throw new NotFoundException('User not found');
}

// ❌ Bad
return null;  // Frontend has no idea what happened
Enter fullscreen mode Exit fullscreen mode

4. Use Environment Variables

// βœ… Good
secret: process.env.JWT_SECRET

// ❌ Bad
secret: 'hardcoded-secret'
Enter fullscreen mode Exit fullscreen mode

5. Validate Everything

// βœ… Good - validation decorators
@IsEmail()
@MinLength(8)

// ❌ Bad - trust user input
password: string;
Enter fullscreen mode Exit fullscreen mode

πŸš€ 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)