The previous modules showed you the main features of express. Now it's time to go to a whole other level and create a real world setup.
The next part will consist of a few large topics:
- NestJS Framework for scalable Node.js applications with dependency injection
- Swagger for automatically generated API documentation
- Authentication and authorization with JWT tokens and guards for access management
- Databases for storing data with Prisma ORM
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), and FRP (Functional Reactive Programming).
NestJS is built with and fully supports TypeScript (yet still enables developers to code in pure JavaScript) and combines elements of OOP, FP, and FRP. Under the hood, NestJS makes use of robust HTTP Server frameworks like Express (the default) and optionally can be configured to use Fastify as well.
NestJS provides a level of abstraction above these common Node.js frameworks (Express/Fastify), but also exposes their APIs directly to the developer. This provides developers with the freedom to use the myriad of third-party modules which are available for the underlying platform.
The default flow of a feature/endpoint always has 4 steps:
- Authorize the user
- Validate the token with Guards
- Validate the access rights to the resource
- Validate the input
- Transform and strip the input with class transformer. Make sure no unknown properties can be provided, parse all items to the correct type (ex. string to date)
- Validate the input with class validator through NestJS validation pipes
- Execute the business logic
- The endpoint lands in the controller, the controller should contain absolutely no logic and should instantly call the handler
- The handler contains all logic but can call several helpers, clients or services
- Representation
- Transform the output of the handler to a predefined view contract
By following these few steps it will be easy to build clean, secure and maintainable API's.
There are several packages we need to add for NestJS framework support:
pnpm add @nestjs/common @nestjs/core @nestjs/platform-express @nestjs/swagger cors
pnpm add -D @nestjs/cli @nestjs/testingWe also need to create the NestJS configuration file:
echo '{"collection": "@nestjs/schematics", "sourceRoot": "src"}' > nest-cli.jsonAdd "types": ["node"] to the compilerOptions in your tsconfig.json:
By default TypeScript automatically includes every @types/* package it
finds in node_modules/@types in the global scope. In a NestJS project with
many (transitive) dependencies that can pull in conflicting or unexpected
ambient type declarations, which surface as confusing errors when we run
tsc --noEmit. Setting "types": ["node"] makes the global scope explicit:
only @types/node (giving you process, Buffer, etc.) is included globally,
and everything else is used through explicit imports. That's why our tests
import their helpers explicitly (import { describe, it } from "mocha" and
import { expect } from "chai").
The initialization of the application works different with NestJS than plain Express. NestJS uses a module-based architecture where we bootstrap the application using NestFactory.
First, create an app.module.ts file that will serve as the root module:
import { Module } from "@nestjs/common";
import { UserController } from "./controllers/users/user.controller";
@Module({
controllers: [UserController],
})
export class AppModule {}Next, replace your server.ts with main.ts to bootstrap the NestJS application:
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { exposeUnsetFields: false },
})
);
// Enable CORS
app.enableCors({
origin: "*",
credentials: true,
exposedHeaders: ["x-auth"],
});
// Set global prefix
app.setGlobalPrefix("api");
await app.listen(3000);
console.log("🚀 http://localhost:3000");
}
bootstrap();You can also delete the old app.ts file as it's no longer needed with NestJS.
In NestJS what makes a controller a controller is simply
the @Controller decorator. Because the class is decorated with this
decorator NestJS will understand how to process the class.
You need to specify the route path in the decorator, in our case this should
be users.
Your result should look like this:
@Controller("users")
export class UserRoute {}However, the name is actually not valid anymore.
The Route is no longer a router but a controller.
Rename the class to UserController.
Make use of the VSCode built in refactoring to rename the class by
pressing [f2] (or [fn]+[f2] depending
on your settings). By changing the name like this all references will
automatically be renamed as well.
Now also rename the file user.controller.ts
Endpoints are defined completely different from what we had before. Instead of constructing the endpoint with express router, we now can simply add a method for each endpoint with some decorators.
Very detailed examples and documentation can be found on the NestJS documentation.
Converting the code:
-
Remove the router and the path properties from the controller
-
Comment the entire constructor for now
-
Add a method for each endpoint
The controller should look like this now:
@Controller("users") export class UserController { // constructor() { // this.router = Router(); // this.path = "users"; // this.router.post("/", adminMiddleware, create); // this.router.get("/", getList); // this.router.get("/:id", get); // this.router.patch("/:id", update); // this.router.delete("/:id", deleteUser); // } async create() {} async getList() {} async get() {} async update() {} async delete() {} }
-
Next up you should tell NestJS that these methods are the routes, specify the path and the http-method.
NestJS has some decorators to accomplish this. For each http-method there is a decorator, the argument you pass defines the path. ex
@Post()When that's done NestJS will actually know the endpoints we have defined.
-
In the method body you can call the handlers now. Currently, that will still give errors as we don't pass the arguments defined in the handlers yet. We'll be fixing that later.
Most of the commented code is now converted so lets clean it up. Remove all lines except the
post; we'll come back to theadminMiddlewareused there later.
The current code:
@Controller("users")
export class UserController {
// this.router.post("/", adminMiddleware, create);
@Post()
async create() {
return create();
}
@Get()
async getList() {
return getList();
}
@Get(":id")
async get() {
return get();
}
@Patch(":id")
async update() {
return update();
}
@Delete(":id")
async delete() {
await deleteUser();
}
}Previously we implemented body validation and transformation manually inside the
handlers with plainToInstance and validate.
Now we will be using NestJS's @Body() decorator to inject the body into the
controller method and have the body automatically transformed and validated.
The decorator should be used just before an argument, for example:
async create(@Body() body: UserBody) {}
The type of UserBody will automatically be inferred and used for the
transformation and validation through NestJS's ValidationPipe that we configured in main.ts.
For updating an entity we don't want to require all properties to be provided.
NestJS will automatically handle partial validation when using class-validator
decorators with @IsOptional() on optional fields.
async update(@Body() body: UserBody) {}
Heads-up on partial updates. With
transform: true, theValidationPiperunsplainToInstance(UserBody, req.body)for you. By default class-transformer sets every@Expose()d property that is missing from the request toundefined. Inupdatewe merge the body into the existing user ({ ...user, ...body }), so thoseundefineds would wipe out fields the client never sent. That is why theValidationPipeinmain.tsis configured withtransformOptions: { exposeUnsetFields: false }. Unset fields are simply left off the transformed object, so aPATCHonly touches the fields it actually includes.
That covers all body validations that previously were done manually. You can
remove the manual validation/transformation (the plainToInstance and validate
calls) from the create and update handlers now.
The query decorator actually works exactly the same as the body decorator, only
instead of processing request.body it processes request.query.
For the get list endpoint we expect a search parameter. In order to validate the
parameter you can add a query contract: src/contracts/search.query.ts where
you validate it as a string and specify it as optional.
Defining the contract:
// search.query.ts
import { IsString, IsOptional } from "class-validator";
export class SearchQuery {
@IsString()
@IsOptional()
public search?: string;
}And use it like this:
async getList(@Query() query: SearchQuery) {}
request.params needs to be injected one by one with the @Param() decorator
from @nestjs/common. Type conversion is not supported here so it will
always inject a string.
It's used like this:
async get(@Param("id") id: string){}
Apply this decorator to all endpoints containing an id.
The last thing we need from the currently commented route definitions is
the authentication logic (the adminMiddleware).
NestJS uses Guards instead of middleware for authentication and authorization.
We'll create a proper JWT guard later in the authentication section, but for now
you can remove the adminMiddleware as we'll replace it with a proper guard.
So you can go ahead and remove the remaining commented code now.
With plain express we had to manually take what we needed from the request. Quite a hassle to do. Definitely when trying to test a handler and you need to mock the request/response.
Since we are injecting only the items we need now, this becomes a lot easier.
- Replace the arguments in each handler with the ones we
need:
body,id,query- Also pass the these arguments to the handler from the controller
- Return the result instead of calling
res.json(...) - Remove the validation/transformation as this has now been abstracted away with the decorators
- Remove the
nextfunction calls - Throw a NestJS
NotFoundExceptionwhen no user is found instead of manually returning an error (we'll come back to this in the next section)
Now all references to request, response and next are gone. The build
errors in the handlers and controller should also be resolved.
user.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { SearchQuery } from "../../contracts/search.query";
import { UserBody } from "../../contracts/user.body";
import { create } from "./handlers/create.handler";
import { deleteUser } from "./handlers/delete.handler";
import { get } from "./handlers/get.handler";
import { getList } from "./handlers/getList.handler";
import { update } from "./handlers/update.handler";
@Controller("users")
export class UserController {
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() body: UserBody) {
return create(body);
}
@Get()
async getList(@Query() query: SearchQuery) {
return getList(query.search);
}
@Get(":id")
async get(@Param("id") id: string) {
return get(id);
}
@Patch(":id")
async update(@Param("id") id: string, @Body() body: UserBody) {
return update(id, body);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param("id") id: string) {
await deleteUser(id);
}
}create.handler.ts
import { UserBody } from "../../../contracts/user.body";
import { UserStore } from "./user.store";
export const create = async (body: UserBody) => {
const user = UserStore.add(body);
return user;
};getList.handler.ts
import { UserStore } from "./user.store";
export const getList = (search: string) => {
const users = UserStore.find(search);
return users;
};get.handler.ts
import { NotFoundException } from "@nestjs/common";
import { UserStore } from "./user.store";
export const get = (idString: string) => {
const id = Number(idString);
const user = UserStore.get(id);
if (!user) {
throw new NotFoundException("User not found");
}
return user;
};update.handler.ts
import { NotFoundException } from "@nestjs/common";
import { UserBody } from "../../../contracts/user.body";
import { UserStore } from "./user.store";
export const update = (idString: string, body: UserBody) => {
const id = Number(idString);
const user = UserStore.get(id);
if (!user) {
throw new NotFoundException("User not found");
}
const updated = UserStore.update(id, { ...user, ...body });
return updated;
};delete.handler.ts
import { NotFoundException } from "@nestjs/common";
import { UserStore } from "./user.store";
export const deleteUser = (idString: string) => {
const id = Number(idString);
const user = UserStore.get(id);
if (!user) {
throw new NotFoundException("User not found");
}
UserStore.delete(id);
};Handling expected errors manually is a bit of a pain as you might have noticed when doing so in a previous section. It's much easier to just be able to throw a NestJS exception and have it handled in the background.
You already implemented the NotFoundException error but there are
other built-in HTTP exceptions
as well.
NestJS automatically handles exceptions for you:
- All unhandled exceptions are automatically caught by the built-in exception filter
- HTTP exceptions are automatically converted to proper HTTP responses
- Async errors are handled automatically (no need for express-async-errors)
The built-in exception filter returns JSON responses in the following format:
{
"statusCode": 404,
"message": "User not found",
"error": "Not Found"
}NestJS comes with many built-in exceptions like:
BadRequestExceptionUnauthorizedExceptionNotFoundExceptionForbiddenExceptionInternalServerErrorException- And many more...
In Module 2 we shaped every response by hand with
res.json(plainToInstance(UserView, user)). Now that our handlers just return
their result, we need to shape that output again, otherwise everything the
handler returns ends up in the response, including the user's password.
It is tempting to think the return type takes care of this:
async get(@Param("id") id: string): Promise<UserView> { ... }It does not. TypeScript types are erased at compile time, so at runtime NestJS
has no idea you meant UserView. NestJS' built-in ClassSerializerInterceptor
can strip fields, but only off objects that are instances of a decorated
class. Our handlers return plain objects (from the UserStore now, and from
Prisma later), so nothing gets stripped and the password leaks.
To fix this cleanly, without sprinkling plainToInstance(...) through every
handler, we use a small, reusable representation layer: a @Serialize(View)
decorator that declares which view an endpoint returns, plus an interceptor that
performs the transformation. You don't have to design these yourself; add the
two files below to your project as-is.
Create src/decorators/serialize.decorator.ts:
import { Type } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
/**
* Marks an endpoint's response to be serialized into the given view contract.
*
* Usage: `@Serialize(UserView)` on a controller method. The TransformInterceptor
* reads this and transforms the handler's return value into the view, stripping
* any field that isn't declared on it.
*/
export const Serialize = Reflector.createDecorator<Type>();Create src/interceptors/transform.interceptor.ts:
import {
ClassSerializerContextOptions,
ClassSerializerInterceptor,
ExecutionContext,
Injectable,
} from "@nestjs/common";
import { Serialize } from "../decorators/serialize.decorator";
/**
* Serializes every response into the view declared with `@Serialize(View)`.
*
* It extends the built-in ClassSerializerInterceptor. We only override
* `getContextOptions` to resolve the requested view from the route metadata and
* put it on `options.type`. The base `intercept()` then runs its normal
* serialize pipeline, which does `plainToInstance(View, response)` for both
* single objects and arrays. `excludeExtraneousValues` makes it a strict
* whitelist: anything not @Expose()d on the view is dropped from the response.
*
* Handlers can therefore return raw domain objects and stay ignorant of the
* representation layer.
*/
@Injectable()
export class TransformInterceptor extends ClassSerializerInterceptor {
protected getContextOptions(
context: ExecutionContext
): ClassSerializerContextOptions | undefined {
const view = this.reflector.getAllAndOverride(Serialize, [
context.getHandler(),
context.getClass(),
]);
// Preserve any options set via the built-in @SerializeOptions().
const base = super.getContextOptions(context);
if (!view) {
return base;
}
return { ...base, type: view, excludeExtraneousValues: true };
}
}Register it in your AppModule so it applies to every endpoint. We register it
here (rather than in main.ts) so the integration tests, which build the app
from AppModule, pick it up automatically as well.
import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR, Reflector } from "@nestjs/core";
import { UserController } from "./controllers/users/user.controller";
import { TransformInterceptor } from "./interceptors/transform.interceptor";
@Module({
controllers: [UserController],
providers: [
// useFactory (rather than useClass) so the Reflector is passed to the
// constructor inherited from ClassSerializerInterceptor.
{
provide: APP_INTERCEPTOR,
useFactory: (reflector: Reflector) =>
new TransformInterceptor(reflector),
inject: [Reflector],
},
],
})
export class AppModule {}Now add @Serialize(UserView) to every endpoint that returns a user. The
handler keeps returning the raw object; the interceptor projects it onto
UserView and drops everything else (like password). It works the same on a
single object and on an array, because the interceptor serializes each element.
import { Serialize } from "../../decorators/serialize.decorator";
import { UserView } from "../../contracts/user.view";
@Controller("users")
export class UserController {
@Post()
@HttpCode(HttpStatus.CREATED)
@Serialize(UserView)
async create(@Body() body: UserBody): Promise<UserView> {
return create(body);
}
@Get()
@Serialize(UserView)
async getList(@Query() query: SearchQuery): Promise<UserView[]> {
return getList(query.search);
}
@Get(":id")
@Serialize(UserView)
async get(@Param("id") id: string): Promise<UserView> {
return get(id);
}
@Patch(":id")
@Serialize(UserView)
async update(
@Param("id") id: string,
@Body() body: UserBody
): Promise<UserView> {
return update(id, body);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param("id") id: string) {
await deleteUser(id);
}
}The view is a plain contract of exactly what may leave your API. Because the
interceptor uses excludeExtraneousValues: true, this is a strict whitelist:
only @Expose()d properties survive, so password can never leak — even if you
add new fields to the underlying model later.
// src/contracts/user.view.ts
import { Exclude, Expose } from "class-transformer";
import { IsEmail, IsNumber, IsString } from "class-validator";
@Exclude()
export class UserView {
@Expose()
@IsNumber()
id: number;
@Expose()
@IsString()
name: string;
@Expose()
@IsEmail()
email: string;
}You can explicitly set HTTP status codes using the @HttpCode() decorator:
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() body: UserBody) {
return create(body);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param("id") id: string) {
await deleteUser(id);
}The get list endpoint returns an array. The handler stays simple:
export const getList = (search: string): User[] => {
const users = UserStore.find(search);
return users;
};The controller declares the element view with @Serialize(UserView), and the
interceptor serializes each item in the array:
@Get()
@Serialize(UserView)
async getList(@Query() query: SearchQuery): Promise<UserView[]> {
return getList(query.search);
}Nest.js uses the following commands to start the app, so add/modify them in package.json:
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"dev": "nest start --watch",
We keep the dev script that modules 1 and 2 established, so the same
pnpm dev (or npm run dev) keeps working here and later when module 4's
monorepo runs it across every package.
While converting the code, we introduced some breaking changes. That means the
previously created tests will no longer work.
The handlers now have different arguments and responses, so we need to adjust the
tests accordingly.
Most endpoints themselves are exactly the same, so the integration tests should
remain mainly the same.
However, there are some small changes like the exception types that need to be updated.
The handlers have a simple, readable signature now, without the bloated express objects. So now you can simplify the tests and remove the mocked request and response. Just pass in the arguments you need and get the return value to validate.
The code
import { expect } from "chai";
import { beforeEach, describe, it } from "mocha";
import { create } from "../../controllers/users/handlers/create.handler";
import { deleteUser } from "../../controllers/users/handlers/delete.handler";
import { get } from "../../controllers/users/handlers/get.handler";
import { getList } from "../../controllers/users/handlers/getList.handler";
import { update } from "../../controllers/users/handlers/update.handler";
import { User, UserStore } from "../../controllers/users/handlers/user.store";
const userFixtures: User[] = [
{
name: "test1",
email: "test-user+1@panenco.com",
id: 0,
password: "password1",
},
{
name: "test2",
email: "test-user+2@panenco.com",
id: 1,
password: "password2",
},
];
describe("Handler tests", () => {
describe("User Tests", () => {
beforeEach(() => {
UserStore.users = [...userFixtures]; // Clone the array
});
it("should get users", () => {
const res = getList(null);
expect(res.some((x) => x.name === "test2")).true;
});
it("should get user by id", () => {
const res = get("1");
expect(res.name).equal("test2");
expect(res.email).equal("test-user+2@panenco.com");
});
it("should fail when getting user by unknown id", () => {
try {
get("999");
} catch (error) {
expect(error.message).equal("User not found");
return;
}
expect(true, "should have thrown an error").false;
});
it("should create user", async () => {
const body = {
email: "test-user+new@panenco.com",
name: "newUser",
password: "reallysecretstuff",
} as User;
const res = await create(body);
expect(res.name).equal("newUser");
expect(res.email).equal("test-user+new@panenco.com");
});
it("should update user", async () => {
const body = {
email: "test-user+updated@panenco.com",
} as User;
const id = 0;
const res = update(id.toString(), body);
expect(res.email).equal(body.email);
expect(res.name).equal("test1");
expect(UserStore.users.find((x) => x.id === id).email).equal(
body.email
);
});
it("should delete user by id", () => {
const initialCount = UserStore.users.length;
deleteUser("1");
expect(UserStore.users.some((x) => x.id === 1)).false;
expect(initialCount - 1).equal(UserStore.users.length);
});
});
});The integration test shouldn't have any build error at this point. So to know if anything needs to change here we can simply run them.
There are a few changes needed:
- Update imports to use NestJS testing utilities
- Remove references to Express-specific response structures
- Update the authentication mechanism (we'll cover this in the next section)
Bonus: You can use HTTP status constants like
200,201,404instead of magic numbers.
The code
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import { expect } from "chai";
import { beforeAll, beforeEach, afterAll, describe, it } from "mocha";
import request from "supertest";
import { AppModule } from "../../app.module";
import { UserBody } from "../../contracts/user.body";
import { User, UserStore } from "../../controllers/users/handlers/user.store";
describe("Integration tests", () => {
describe("User Tests", () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule(
{
imports: [AppModule],
}
).compile();
app = moduleFixture.createNestApplication();
// Apply the same configuration as in main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { exposeUnsetFields: false },
})
);
app.enableCors({
origin: "*",
credentials: true,
exposedHeaders: ["x-auth"],
});
app.setGlobalPrefix("api");
await app.init();
});
beforeEach(() => {
UserStore.users = []; // Clean up users before each test
});
afterAll(async () => {
await app.close();
});
it("should CRUD users", async () => {
// Successfully create new user
const { body: createResponse } = await request(app.getHttpServer())
.post(`/api/users`)
.send({
name: "test",
email: "test-user+1@panenco.com",
password: "real secret stuff",
} as User)
.expect(201);
expect(
UserStore.users.some((x) => x.email === createResponse.email)
).true;
// Get the newly created user
const { body: getResponse } = await request(app.getHttpServer())
.get(`/api/users/${createResponse.id}`)
.expect(200);
expect(getResponse.name).equal("test");
// Get all users
const { body: getListRes } = await request(app.getHttpServer())
.get(`/api/users`)
.expect(200);
expect(getListRes.length).equal(1);
expect(getListRes[0].name).equal("test");
// Successfully update user
const { body: updateResponse } = await request(app.getHttpServer())
.patch(`/api/users/${createResponse.id}`)
.send({
email: "test-user+1@panenco.com",
} as User)
.expect(200);
expect(updateResponse.name).equal("test");
expect(updateResponse.email).equal("test-user+1@panenco.com");
expect(updateResponse.password).undefined; // password excluded from response
// Delete the newly created user
await request(app.getHttpServer())
.delete(`/api/users/${createResponse.id}`)
.expect(204);
// Get all users again after deleted the only user
const { body: getNoneResponse } = await request(app.getHttpServer())
.get(`/api/users`)
.expect(200);
expect(getNoneResponse.length).equal(0);
});
});
});🥳 🚀 That's all for the NestJS conversion of the API. Next up... Authentication
Currently most of our endpoints are publicly available. NestJS provides a robust authentication system using Guards to protect your routes.
We'll not go into a lot of detail on the basic principles here, but the Udemy course has a quite extensive explanation on this topic: Section 12: API Authentication and Security (Task App)
A brief intro:
- Authentication: Verify a user's identity by checking their credentials. In our
case we will always return a
JWT token- When a user provides invalid credentials, we should return
a
401 Unauthorizederror.
- When a user provides invalid credentials, we should return
a
- Authorization: Use the JWT token to verify whether the user has access to the
requested resource.
- When the token itself is invalid, we should return a
401 Unauthorizederror. - When the token is valid but the user has no access to the resource we
should return a
403 Forbiddenerror.
- When the token itself is invalid, we should return a
The @UseGuards() decorator should be used to mark the endpoint as requiring a
token.
You can create custom guards to add validations like isAdmin, belongsToOrganization, etc. But we
won't be covering custom requirements.
Have a look at the NestJS Guards documentation for more info.
You can make authenticating users quite complex but to get you started we'll be implementing the most basic and unsecure login ever.
The login endpoint should exist in a new controller, the AuthController.
First, add the JWT dependency:
pnpm add jsonwebtoken
pnpm add -D @types/jsonwebtoken- Create the controller (in a new
authfolder) - Add the new exported class
AuthControllerwith the@Controllerdecorator and register it in theAppModule. - Create a new
LoginBodycontract that contains password and email - Create a new
AccessTokenViewcontract that contains token and expiresIn - Add a handler in
src/controllers/auth/handlers/login.handler.tsthat will handle the login request.- As input you have the
LoginBodycontract - As output you have the
AccessTokenViewcontract - Use
getByEmailfrom theUserStoreto find the user - Validate the user's password
- Simply compare both passwords in plain text. (this is a bad idea, it's just an example)
- Either if the user is not found or the password is wrong, throw
an
UnauthorizedException
- Use the
jsonwebtokenlibrary to create a JWT token- secret: used to verify the fact that our application created the
token (add this in a
config.tsfile in thesrcfolder (see below)) - expiresIn: amount of time the token should be valid (put it to 1 hour)
- payload: you can put basically anything in there. In our case we'll just put the user id
- return the result
- secret: used to verify the fact that our application created the
token (add this in a
- As input you have the
- Create the endpoint in the controller with the
LoginBody,AccessTokenViewand call the handler
When testing this you can check the contents of the JWT token
on jwt.io

config.ts
const config = {
jwtSecret: "very_secure_jwt_secret",
};
export default config;login.body.ts
import { IsEmail, IsString } from "class-validator";
export class LoginBody {
@IsEmail()
public email: string;
@IsString()
public password: string;
}accessToken.view.ts
import { IsNumber, IsString } from "class-validator";
export class AccessTokenView {
@IsString()
public token: string;
@IsNumber()
public expiresIn: number;
}auth.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus } from "@nestjs/common";
import { AccessTokenView } from "../../contracts/accessToken.view";
import { LoginBody } from "../../contracts/login.body";
import { createToken } from "./handlers/login.handler";
@Controller("auth")
export class AuthController {
// A POST defaults to 201 Created in NestJS, but a login isn't creating a
// resource — override it to 200 OK (the integration tests expect 200).
@Post("login")
@HttpCode(HttpStatus.OK)
async login(@Body() body: LoginBody): Promise<AccessTokenView> {
return createToken(body);
}
}login.handler.ts
import { UnauthorizedException } from "@nestjs/common";
import * as jwt from "jsonwebtoken";
import { LoginBody } from "../../../contracts/login.body";
import { UserStore } from "../../users/handlers/user.store";
import config from "../../../config";
export const createToken = async (body: LoginBody) => {
const user = UserStore.getByEmail(body.email);
if (!user || user.password !== body.password) {
throw new UnauthorizedException("Invalid credentials");
}
const token = jwt.sign({ userId: user.id }, config.jwtSecret, {
expiresIn: "1h",
});
return {
token,
expiresIn: 3600, // 1 hour in seconds
};
};The guard is the thing that will verify the JWT token and protect your routes.
Create a JWT guard src/guards/jwt-auth.guard.ts:
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from "@nestjs/common";
import * as jwt from "jsonwebtoken";
import config from "../config";
@Injectable()
export class JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers["x-auth"];
if (!token) {
throw new UnauthorizedException("Token not provided");
}
try {
const payload = jwt.verify(token, config.jwtSecret) as any;
request.user = payload;
return true;
} catch (error) {
throw new UnauthorizedException("Invalid token");
}
}
}Now when we use the @UseGuards(JwtAuthGuard) decorator, this guard will be
used to validate the tokens created by the login handler.
All preparations have been done, and it's finally time to secure our endpoints!
First, update your AppModule to include the AuthController:
import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR, Reflector } from "@nestjs/core";
import { AuthController } from "./controllers/auth/auth.controller";
import { UserController } from "./controllers/users/user.controller";
import { TransformInterceptor } from "./interceptors/transform.interceptor";
@Module({
controllers: [AuthController, UserController],
providers: [
{
provide: APP_INTERCEPTOR,
useFactory: (reflector: Reflector) =>
new TransformInterceptor(reflector),
inject: [Reflector],
},
],
})
export class AppModule {}Simply add the @UseGuards(JwtAuthGuard) decorator to your users endpoints and the endpoints
will no longer be freely available. Secure get, getList, patch and delete. Leave
the POST endpoint publicly available so you can still create new users.
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { JwtAuthGuard } from "../../guards/jwt-auth.guard";
import { Serialize } from "../../decorators/serialize.decorator";
import { UserView } from "../../contracts/user.view";
// Keep the @Serialize(UserView) decorators from the Response Serialization
// section; here we only add the @UseGuards(JwtAuthGuard) guard on top.
@Controller("users")
export class UserController {
@Post()
@HttpCode(HttpStatus.CREATED)
@Serialize(UserView)
async create(@Body() body: UserBody) {
return create(body);
}
@Get()
@UseGuards(JwtAuthGuard)
@Serialize(UserView)
async getList(@Query() query: SearchQuery) {
return getList(query.search);
}
@Get(":id")
@UseGuards(JwtAuthGuard)
@Serialize(UserView)
async get(@Param("id") id: string) {
return get(id);
}
@Patch(":id")
@UseGuards(JwtAuthGuard)
@Serialize(UserView)
async update(@Param("id") id: string, @Body() body: UserBody) {
return update(id, body);
}
@Delete(":id")
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param("id") id: string) {
await deleteUser(id);
}
}When running your integration test now you'll notice it's failing with
Unauthorized errors. Change it up a bit to first create a new user, then
login and use the response token in the x-auth header to access the other
endpoints.
Here's an updated integration test that demonstrates the authentication flow:
it("should CRUD users with authentication", async () => {
// Successfully create new user (public endpoint)
const { body: createResponse } = await request(app.getHttpServer())
.post(`/api/users`)
.send({
name: "test",
email: "test-user+1@panenco.com",
password: "real secret stuff",
})
.expect(201);
expect(UserStore.users.some((x) => x.email === createResponse.email)).true;
// Login to get JWT token
const { body: loginResponse } = await request(app.getHttpServer())
.post(`/api/auth/login`)
.send({
email: "test-user+1@panenco.com",
password: "real secret stuff",
})
.expect(200);
const token = loginResponse.token;
expect(token).to.be.a("string");
// Try to access protected endpoint without token (should fail)
await request(app.getHttpServer()).get(`/api/users`).expect(401);
// Get all users with valid token
const { body: getListRes } = await request(app.getHttpServer())
.get(`/api/users`)
.set("x-auth", token)
.expect(200);
expect(getListRes.length).equal(1);
expect(getListRes[0].name).equal("test");
// Get the newly created user with token
const { body: getResponse } = await request(app.getHttpServer())
.get(`/api/users/${createResponse.id}`)
.set("x-auth", token)
.expect(200);
expect(getResponse.name).equal("test");
// Successfully update user with token
const { body: updateResponse } = await request(app.getHttpServer())
.patch(`/api/users/${createResponse.id}`)
.send({
email: "test-user+updated@panenco.com",
})
.set("x-auth", token)
.expect(200);
expect(updateResponse.name).equal("test");
expect(updateResponse.email).equal("test-user+updated@panenco.com");
expect(updateResponse.password).undefined; // password excluded from response
// Delete the user with token
await request(app.getHttpServer())
.delete(`/api/users/${createResponse.id}`)
.set("x-auth", token)
.expect(204);
// Verify user is deleted
const { body: getNoneResponse } = await request(app.getHttpServer())
.get(`/api/users`)
.set("x-auth", token)
.expect(200);
expect(getNoneResponse.length).equal(0);
});user.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { SearchQuery } from "../../contracts/search.query";
import { UserBody } from "../../contracts/user.body";
import { UserView } from "../../contracts/user.view";
import { create } from "./handlers/create.handler";
import { deleteUser } from "./handlers/delete.handler";
import { get } from "./handlers/get.handler";
import { getList } from "./handlers/getList.handler";
import { update } from "./handlers/update.handler";
import { JwtAuthGuard } from "../../guards/jwt-auth.guard";
import { Serialize } from "../../decorators/serialize.decorator";
@Controller("users")
export class UserController {
@Post()
@HttpCode(HttpStatus.CREATED)
@Serialize(UserView)
async create(@Body() body: UserBody): Promise<UserView> {
return create(body);
}
@Get()
@UseGuards(JwtAuthGuard)
@Serialize(UserView)
async getList(@Query() query: SearchQuery): Promise<UserView[]> {
return getList(query.search);
}
@Get(":id")
@UseGuards(JwtAuthGuard)
@Serialize(UserView)
async get(@Param("id") id: string): Promise<UserView> {
return get(id);
}
@Patch(":id")
@UseGuards(JwtAuthGuard)
@Serialize(UserView)
async update(
@Param("id") id: string,
@Body() body: UserBody
): Promise<UserView> {
return update(id, body);
}
@Delete(":id")
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param("id") id: string): Promise<void> {
await deleteUser(id);
}
}user.integration.test.ts with NestJS
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import { expect } from "chai";
import { beforeAll, beforeEach, afterAll, describe, it } from "mocha";
import request from "supertest";
import { AppModule } from "../../app.module";
import { UserBody } from "../../contracts/user.body";
import { UserStore } from "../../controllers/users/handlers/user.store";
describe("Integration tests", () => {
describe("User Tests", () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule(
{
imports: [AppModule],
}
).compile();
app = moduleFixture.createNestApplication();
// Apply the same configuration as in main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { exposeUnsetFields: false },
})
);
app.enableCors({
origin: "*",
credentials: true,
exposedHeaders: ["x-auth"],
});
app.setGlobalPrefix("api");
await app.init();
});
beforeEach(() => {
// Clean up in-memory store before each test
UserStore.users = [];
});
afterAll(async () => {
await app.close();
});
it("should CRUD users with authentication", async () => {
// Test unauthorized access
await request(app.getHttpServer()).get(`/api/users`).expect(401);
// Successfully create new user (public endpoint)
const { body: createResponse } = await request(app.getHttpServer())
.post(`/api/users`)
.send({
name: "test",
email: "test-user+1@panenco.com",
password: "real secret stuff",
} as UserBody)
.expect(201);
// Verify user was created in memory store
expect(
UserStore.users.some((x) => x.email === createResponse.email)
).true;
// Login to get JWT token
const { body: loginResponse } = await request(app.getHttpServer())
.post(`/api/auth/login`)
.send({
email: "test-user+1@panenco.com",
password: "real secret stuff",
})
.expect(200);
const token = loginResponse.token;
expect(token).to.be.a("string");
// Get all users with valid token
const { body: getListRes } = await request(app.getHttpServer())
.get(`/api/users`)
.set("x-auth", token)
.expect(200);
expect(getListRes.length).equal(1);
expect(getListRes[0].name).equal("test");
// Get the newly created user with token
const { body: getResponse } = await request(app.getHttpServer())
.get(`/api/users/${createResponse.id}`)
.set("x-auth", token)
.expect(200);
expect(getResponse.name).equal("test");
// Successfully update user with token
const { body: updateResponse } = await request(app.getHttpServer())
.patch(`/api/users/${createResponse.id}`)
.send({
email: "test-user+updated@panenco.com",
})
.set("x-auth", token)
.expect(200);
expect(updateResponse.name).equal("test");
expect(updateResponse.email).equal("test-user+updated@panenco.com");
expect(updateResponse.password).undefined; // password excluded from response
// Delete the user with token
await request(app.getHttpServer())
.delete(`/api/users/${createResponse.id}`)
.set("x-auth", token)
.expect(204);
// Verify user is deleted
const { body: getNoneResponse } = await request(app.getHttpServer())
.get(`/api/users`)
.set("x-auth", token)
.expect(200);
expect(getNoneResponse.length).equal(0);
});
});
});Manually writing and maintaining API documentation is a horrible task to do. Some things will always be incorrect or not up to date. Frontend developers will have a terrible experience as they don't have a correct guide on what endpoints there are and what they do.
Luckily it's amazingly easy to generate these docs automatically because of the decorator based approach we're using.
2 things to understand:
- OpenAPI: A specification for how to describe an API. It's the standardized format we need to serve into a graphical interface of our endpoints. It will contain all information about the contracts and endpoints.
- Swagger: The actual interface we use to display the documentation.
NestJS has built-in Swagger support that's much easier to set up:
pnpm add @nestjs/swagger swagger-ui-express@nestjs/swaggerprovides the built-in Swagger integrationswagger-ui-expressto display the documentation
NestJS provides built-in Swagger support that's much easier to configure. Update your main.ts file to include Swagger configuration:
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { exposeUnsetFields: false },
})
);
// Enable CORS
app.enableCors({
origin: "*",
credentials: true,
exposedHeaders: ["x-auth"],
});
// Set global prefix
app.setGlobalPrefix("api");
// Swagger configuration
const config = new DocumentBuilder()
.setTitle("Node Course API")
.setDescription("The Node Course API description")
.setVersion("1.0")
.addApiKey(
{
type: "apiKey",
name: "x-auth",
in: "header",
},
"x-auth"
)
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("docs", app, document);
await app.listen(3000);
console.log("🚀 http://localhost:3000/docs");
}
bootstrap();This will already give you very handy docs. However if you have 100+ endpoints,
not every endpoint might be very self explanatory.
To fix that we can add some descriptions using NestJS Swagger decorators.
Add Swagger decorators to your controllers. The @Serialize(UserView) and
@UseGuards(JwtAuthGuard) decorators are already there from the earlier sections
— here we're only layering the @Api* documentation decorators on top:
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiSecurity,
} from "@nestjs/swagger";
import { Serialize } from "../../decorators/serialize.decorator";
import { UserView } from "../../contracts/user.view";
@ApiTags("users")
@Controller("users")
export class UserController {
@Post()
@HttpCode(HttpStatus.CREATED)
@Serialize(UserView)
@ApiOperation({ summary: "Create a new user" })
@ApiResponse({
status: 201,
description: "User created successfully",
type: UserView,
})
async create(@Body() body: UserBody) {
return create(body);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiSecurity("x-auth")
@Serialize(UserView)
@ApiOperation({ summary: "Get all users" })
@ApiResponse({
status: 200,
description: "Users retrieved successfully",
type: [UserView],
})
async getList(@Query() query: SearchQuery) {
return getList(query.search);
}
}Always pass
typeon your success response. Without it, Swagger records the status code and description but no response schema, soUserViewis never referenced anywhere in the spec and won't be emitted undercomponents.schemas. In Module 4 we generate a typed SDK straight from this spec. If the schema isn't there, theUserViewtype simply won't exist in the generated client. Note the array notationtype: [UserView]for endpoints that return a list.
These look related but operate at completely different layers, and one cannot replace the other:
@Serialize(UserView)is a runtime mechanism. The interceptor actually transforms the object your handler returns and strips anything that isn't@Expose()d on the view. That's what keeps thepasswordout of the real HTTP response body sent to the client.@ApiResponse({ type: UserView })is documentation only. It only describes the response in the OpenAPI spec (and therefore in the generated SDK). It does not touch the runtime response at all. It's a claim about the shape, not an enforcement of it.
So they are not redundant, and you need both. If you removed @Serialize and
kept only @ApiResponse, the spec/SDK would advertise a clean UserView while
the API kept leaking the password over the wire — the documentation would be
lying. @Serialize is our single, reusable representation layer: keep it on every
endpoint that returns a view, and let your handlers return the raw record (as they
still do once they talk to Prisma in the database section). Don't reach for a
per-handler plainToInstance(...). The interceptor already does exactly that, in
one place, for every response.
You can go ahead and start up your API and checkout the docs
on localhost:3000/docs.

For instance, execute a flow in there:
- Create a user
- Get login
- Configure it in the top right with the "Authorize" button
- Get your user
Your NestJS application is now properly configured with Swagger documentation!
Up until now we've only used a minimalistic in-memory store. Obviously that's not something you can use for a real product. So next up, it's time to use a real database. For this example we'll be using Postgres running in a docker container.
Docker is a way to virtually run operating systems preinstalled with certain software to run on your pc. No deep understanding of docker is needed for this course but there is a ton of information online if you're interested.
If it's not yet installed, you can install docker with brew:
brew install dockerIn order to get some extra functionality, you also need to install Docker Desktop.
To configure the docker container, we'll create a compose file. This holds information about the image to use and the variables used to configure it.
-
Add a
docker-compose.ymlfile in the root of the project:version: "3" services: postgres: container_name: example-postgres image: postgres:latest ports: - "5432:5432" volumes: - ./data/postgres:/data/example-postgres env_file: - docker.env
-
Add a
docker.envfile in the root of the project to configure postgres:POSTGRES_USER=root POSTGRES_PASSWORD=root POSTGRES_DB=example POSTGRES_CONTAINER=example-postgres
The compose file has a default naming format so with the filename as it was previously specified it will automatically be found.
Running a container is as simple as running docker-compose up in your terminal
but we want to start it in the background so we'll add -d flag, which gives
us:
docker compose up -dThat's it, now you have a postgres database running on your pc, exposed on the
default port 5432.
A great tool to checkout, query and update the data in a database is TablePlus. Install it by following the instructions on their website.
When opening the app, you should see something like this:

Adding the database:
Naturally, there is not a lot to see here yet because we haven't created any tables or data yet. We'll do that next.
With our CRUD routes and database in place it is now time to migrate our handlers from using the in-memory store to functions modifying and reading our PostgreSQL database.
To query tables in our PostgreSQL database, SQL statements are used. Instead of writing plain SQL statements (SELECT, UPDATE, INSERT,...) in our JS handlers, we use Object-Relational Mappers (ORMs) that do the heavy lifting for us.
An ORM provides a mapping between your database and your object oriented programming language of choice, allowing you to query and manipulate data using the OOP paradigms we all know and love. For better understanding of ORMs, read this stack overflow explanation.
Each ORM comes with its own superset of functionalities and advantages/disadvantages. While carefully following up on the newest industry trends, we choose to use Prisma.
Prisma is a modern TypeScript ORM with an intuitive data model, automated migrations, type-safety, and auto-completion.
Prisma consists of two main packages: the Prisma CLI for development tasks and the Prisma Client for runtime database access. We also need bcryptjs for password hashing:
pnpm add prisma@6 @prisma/client@6 bcryptjs
pnpm add -D @types/bcryptjsFirst, we need to initialize Prisma in our project. We just installed Prisma 6
above, so run that pinned local binary with pnpm exec — not pnpm dlx,
which would download and run the latest Prisma (7) instead:
pnpm exec prisma initThis creates:
- A
prismadirectory with aschema.prismafile - A
.envfile for environment variables
Prisma uses a single schema file to define your database structure. Replace the content of prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
name String
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}prisma init also generated a .env file in the root of your project. Prisma
automatically loads this file and reads DATABASE_URL from it. By default it is
filled in with a placeholder Prisma Postgres URL, which points at a database that
doesn't exist locally. If you leave it as-is you'll get connection errors (like
the URL must start with the protocol ... or a "service not found" error) when
you run migrations or start the app.
Replace the generated DATABASE_URL in .env with the connection string for the
Postgres container we're running locally — the same value we configured in
docker.env:
# .env
DATABASE_URL="postgresql://root:root@localhost:5432/example?schema=public"Also add the same DATABASE_URL to your docker.env so all database config
lives together:
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_DB=example
POSTGRES_CONTAINER=example-postgres
DATABASE_URL="postgresql://root:root@localhost:5432/example?schema=public"Create a Prisma client instance that will be used throughout your application. Create src/lib/prisma.ts:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;With NestJS, you can connect to Prisma during the application bootstrap process. Update your main.ts to initialize the database connection:
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module";
import { prisma } from "./lib/prisma";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { exposeUnsetFields: false },
})
);
// Enable CORS
app.enableCors({
origin: "*",
credentials: true,
exposedHeaders: ["x-auth"],
});
// Set global prefix
app.setGlobalPrefix("api");
// Connect to database
await prisma.$connect();
console.log("Database connected successfully");
await app.listen(3000);
console.log("🚀 http://localhost:3000");
}
bootstrap().catch(console.error);Alternatively, you can create a custom Prisma service and inject it as a provider in your NestJS modules for better dependency injection patterns.
So now our application can connect to the database, however it remains empty and is not being used.
With Prisma, your database schema is defined in a single schema.prisma file using Prisma's schema language. This is much simpler than maintaining separate entity files.
Our User model is already defined in the schema file we created earlier:
model User {
id String @id @default(uuid())
name String
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}Key features of this schema:
- We are using UUIDs (String type with
@default(uuid())) instead of numerical incremental ids - The
@uniquedirective ensures email uniqueness createdAtandupdatedAtare automatically managed by Prisma@@map("users")maps the model to a "users" table in the database
Now that ids are UUID strings instead of incremental numbers, the UserView
contract needs to reflect that. Swap the numeric id (@IsNumber() id: number)
for a UUID string (@IsUUID() id: string):
// src/contracts/user.view.ts
import { Exclude, Expose } from "class-transformer";
import { IsEmail, IsString, IsUUID } from "class-validator";
@Exclude()
export class UserView {
@Expose()
@IsUUID()
id: string;
@Expose()
@IsString()
name: string;
@Expose()
@IsEmail()
email: string;
// password is automatically excluded because it's not @Expose()d
}From here on we run the Prisma CLI through package.json scripts. This keeps us
on the pinned local Prisma 6: pnpm dlx prisma ... would download and run the
latest Prisma (7) and generate a client that mismatches the @prisma/client@6
we installed. Add these scripts to your package.json:
{
"scripts": {
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio",
"db:seed": "ts-node prisma/seed.ts"
}
}After defining your schema, generate the Prisma Client:
pnpm db:generateThis creates type-safe client code based on your schema that you can use in your application.
Having defined the schema, this now needs to be translated to SQL statements to create the accompanying database schema.
Prisma has excellent built-in support for database migrations with automatic generation and version control.
We'll use the db:* scripts added above. To create and execute a migration:
- Make sure your database is up and running:
docker compose up -d - For development, you can use:
pnpm db:push(pushes schema without creating migration files) - For production migrations:
pnpm db:migrate(creates migration files and applies them) - Generate the Prisma client:
pnpm db:generate
Prisma automatically reads DATABASE_URL from the .env file we configured
above, so you don't need to pass it on the command line.
For development, you typically use prisma db push which is faster:
pnpm db:pushNow that the schema is pushed, a refresh of your database (⌘+R or ctrl+R) in
TablePlus should show you the users table with all the properties we defined.
To find out more details about your database schema, click the 'Structure' button in the bottom of the TablePlus app (located next to Data).
Since database migrations can run on large amounts of critical production data, Prisma provides safety features:
- Migrations are executed in transactions
- Migration history is tracked in the
_prisma_migrationstable - You can reset your database:
prisma migrate reset
The schema is in place, but the users table is still empty. Rather than
creating a user through the API every time you reset the database, we'll add a
seed script that populates it with a couple of known users. This is handy
for local development, and later on in module 4 the frontend expects a seeded
john@example.com account to log in with.
We already registered the db:seed script above
("db:seed": "ts-node prisma/seed.ts"). Now create the script it points to,
prisma/seed.ts:
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
// Clear existing data
await prisma.user.deleteMany();
// Create initial users
const users = await Promise.all([
prisma.user.create({
data: {
name: "John Doe",
email: "john@example.com",
password: await bcrypt.hash("password123", 10),
},
}),
prisma.user.create({
data: {
name: "Jane Smith",
email: "jane@example.com",
password: await bcrypt.hash("password456", 10),
},
}),
]);
console.log("Seeded users:", users);
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});A few things worth noting:
- The passwords are hashed with
bcrypt, exactly like thecreatehandler does, so the seeded users can actually log in through the auth flow. deleteMany()at the top makes the script idempotent: run it as often as you like and you always end up with the same known set of users.
With the database running and the schema pushed, seed it:
pnpm db:seedRefresh TablePlus and you should now see the two users in the users table.
With the database connected and our schema in place, we are now ready to update the handlers to insert, update, fetch and delete the users from the database.
The Prisma client we created in src/lib/prisma.ts can be imported and used anywhere in your application. Each method on the client is automatically type-safe based on your schema.
Now we replace all occurrences of the UserStore with Prisma client queries:
- Import the Prisma client in each handler:
import { prisma } from "../../../lib/prisma"; - Add password hashing with bcrypt for security:
import bcrypt from "bcryptjs"; - Use Prisma's intuitive query methods:
create,findMany,findUnique,update,delete - For search functionality, use Prisma's
containsfilter with case-insensitive mode - Remove the UserStore
Key features:
- Type-safe queries with auto-completion
- Automatic password hashing in create and update handlers
- Intuitive query syntax
create.handler.ts
import { prisma } from "../../../lib/prisma";
import { UserBody } from "../../../contracts/user.body";
import bcrypt from "bcryptjs";
export const create = async (body: UserBody) => {
const hashedPassword = await bcrypt.hash(body.password, 10);
const user = await prisma.user.create({
data: {
name: body.name,
email: body.email,
password: hashedPassword,
},
});
return user;
};getList.handler.ts
import { prisma } from "../../../lib/prisma";
export const getList = async (search?: string) => {
const where = search
? {
OR: [
{
name: {
contains: search,
mode: "insensitive" as const,
},
},
{
email: {
contains: search,
mode: "insensitive" as const,
},
},
],
}
: {};
const users = await prisma.user.findMany({
where,
orderBy: { createdAt: "desc" },
});
return [users, users.length];
};get.handler.ts
import { NotFoundException } from "@nestjs/common";
import { prisma } from "../../../lib/prisma";
export const get = async (id: string) => {
const user = await prisma.user.findUnique({
where: { id },
});
if (!user) {
throw new NotFoundException("User not found");
}
return user;
};update.handler.ts
import { NotFoundException } from "@nestjs/common";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
import { UserBody } from "../../../contracts/user.body";
export const update = async (id: string, body: Partial<UserBody>) => {
const existingUser = await prisma.user.findUnique({
where: { id },
});
if (!existingUser) {
throw new NotFoundException("User not found");
}
const updateData: any = {};
if (body.name !== undefined) updateData.name = body.name;
if (body.email !== undefined) updateData.email = body.email;
if (body.password !== undefined) {
updateData.password = await bcrypt.hash(body.password, 10);
}
return prisma.user.update({
where: { id },
data: updateData,
});
};delete.handler.ts
import { NotFoundException } from "@nestjs/common";
import { prisma } from "../../../lib/prisma";
export const deleteUser = async (id: string) => {
const existingUser = await prisma.user.findUnique({
where: { id },
});
if (!existingUser) {
throw new NotFoundException("User not found");
}
await prisma.user.delete({
where: { id },
});
};login.handler.ts
import { UnauthorizedException } from "@nestjs/common";
import * as jwt from "jsonwebtoken";
import { prisma } from "../../../lib/prisma";
import bcrypt from "bcryptjs";
import config from "../../../config";
import { LoginBody } from "../../../contracts/login.body";
export const createToken = async (body: LoginBody) => {
const user = await prisma.user.findUnique({
where: { email: body.email },
});
if (!user) {
throw new UnauthorizedException("Invalid credentials");
}
const isPasswordValid = await bcrypt.compare(body.password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException("Invalid credentials");
}
const token = jwt.sign({ userId: user.id }, config.jwtSecret, {
expiresIn: "1h",
});
return {
token,
expiresIn: 3600, // 1 hour in seconds
};
};As you might have noticed, looking for an entity and throwing a NotFoundException error
when not found, is a commonly returning pattern. With Prisma, this pattern is
straightforward - you simply check if the result is null and throw the
appropriate error.
Prisma's findUnique method returns null when no record is found, making it
easy to handle the not-found case consistently across your handlers:
const user = await prisma.user.findUnique({
where: { id },
});
if (!user) {
throw new NotFoundException("User not found");
}This approach is explicit and easy to understand.
At this point, our handlers exchanged the UserStore for working with a database, but our tests have not been updated.
For integration tests:
- Create a NestJS test application using the Test module
- Connect to the database during test setup
- Clear the database using Prisma before each test
user.integration.test.ts
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import request from "supertest";
import { AppModule } from "../../app.module";
import { prisma } from "../../lib/prisma";
describe("Integration tests", () => {
describe("User Tests", () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule(
{
imports: [AppModule],
}
).compile();
app = moduleFixture.createNestApplication();
// Configure app like in main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { exposeUnsetFields: false },
})
);
app.enableCors({
origin: "*",
credentials: true,
exposedHeaders: ["x-auth"],
});
app.setGlobalPrefix("api");
await app.init();
// Connect to database
await prisma.$connect();
});
beforeEach(async () => {
// Clean up database before each test
await prisma.user.deleteMany();
});
afterAll(async () => {
await app.close();
});
// ... your tests here
});
});For our handler tests:
- Before running the suite, create test fixtures using Prisma
- Clean up the database before each test using
prisma.user.deleteMany()
user.handler.test.ts
import { expect } from "chai";
import { beforeEach, describe, it } from "mocha";
import bcrypt from "bcryptjs";
import { randomUUID } from "node:crypto";
import { create } from "../../controllers/users/handlers/create.handler";
import { deleteUser } from "../../controllers/users/handlers/delete.handler";
import { get } from "../../controllers/users/handlers/get.handler";
import { getList } from "../../controllers/users/handlers/getList.handler";
import { update } from "../../controllers/users/handlers/update.handler";
import { prisma } from "../../lib/prisma";
const userFixtures = [
{
name: "test1",
email: "test-user+1@panenco.com",
password: "password1",
},
{
name: "test2",
email: "test-user+2@panenco.com",
password: "password2",
},
];
describe("Handler tests", () => {
describe("User Tests", () => {
let users: any[];
beforeEach(async () => {
// Clean up database
await prisma.user.deleteMany();
// Create test users
users = await Promise.all(
userFixtures.map(async (fixture) => {
const hashedPassword = await bcrypt.hash(
fixture.password,
10
);
return prisma.user.create({
data: {
name: fixture.name,
email: fixture.email,
password: hashedPassword,
},
});
})
);
});
it("should get users", async () => {
const [res] = await getList(undefined);
expect(res.some((x) => x.name === "test2")).true;
});
it("should get user by id", async () => {
const res = await get(users[1].id);
expect(res.name).equal("test2");
expect(res.email).equal("test-user+2@panenco.com");
});
it("should fail when getting user by unknown id", async () => {
try {
await get(randomUUID());
} catch (error) {
expect(error.message).equal("User not found");
return;
}
expect(true, "should have thrown an error").false;
});
it("should create user", async () => {
const body = {
email: "test-user+new@panenco.com",
name: "newUser",
password: "reallysecretstuff",
};
const res = await create(body);
expect(res.name).equal("newUser");
expect(res.email).equal("test-user+new@panenco.com");
});
it("should update user", async () => {
const body = {
email: "test-user+updated@panenco.com",
};
const id = users[0].id;
const res = await update(id, body);
expect(res.email).equal(body.email);
expect(res.name).equal("test1");
});
it("should delete user by id", async () => {
const initialCount = await prisma.user.count();
await deleteUser(users[0].id);
const newCount = await prisma.user.count();
expect(initialCount - 1).equal(newCount);
});
});
});



{ "compilerOptions": { // ...existing options "types": ["node"] } }