DEV Community

Cover image for How to build an ecommerce app with NestJS
Matt Angelosanto for LogRocket

Posted on • Edited on • Originally published at blog.logrocket.com

How to build an ecommerce app with NestJS

Written by Ivaylo Gerchev✏️

NestJS is one of the best Node frameworks for building server-side applications. In this tutorial, we'll explore how to build a simple NestJS ecommerce app, demonstrating many of Nest’s major features along the way. We’ll cover:

Getting started with our NestJS ecommerce app

By default, NestJS uses Express under the hood, although you have the option to use Fastify instead. Nest provides a solid application architecture, while Express and Fastify are strong HTTP server frameworks with a myriad of features for app development.

Having robust architecture gives you the ability to build highly scalable, testable, loosely coupled, and easy-to-maintain applications. Using Nest enables you to take your Node.js backend to the next level.

Nest is heavily inspired by Angular and borrows many of its concepts. If you already use Angular, Nest could be the perfect match.

To follow this tutorial, you will need at least basic knowledge of and experience with Node, MongoDB, TypeScript, and Nest. Make sure you have Node and MongoDB installed on your machine.

Nest features you should know

Let’s take a moment to review the main Nest features: modules, controllers, and services.

Modules are the main strategy to organize and structure Nest app. There must be at least one root module to create an app. Each module can contain controllers and services, and even other modules.

Nest uses the dependency injection pattern to join modules with their dependencies. To make a class injectable, Nest uses an @Injectable decorator. Then, to provide the class in a module or in a controller, it uses the constructor-based dependency injection.

Controllers handle incoming HTTP requests, validate parameters, and return responses to the client. Controllers should be kept clean and simple, which is where the next Nest feature comes into play.

Services hold most of the business logic and app functionality for your Nest projects. Any complex logic should be provided via services. In fact, services fall under a main type of class called providers.

A provider is just a class injected as a dependency. Other types of a provider which might be used include classes like repositories, factories, helpers, etc.

Creating a new Nest project for our ecommerce app

When you're ready, let's initialize a new Nest project. First, we’ll install Nest CLI. Then, we will create a new project:

npm install -g @nestjs/cli
nest new nestjs-ecommerce
Enter fullscreen mode Exit fullscreen mode

After installation is complete, navigate to the project and start it:

cd nestjs-ecommerce
npm run start:dev
Enter fullscreen mode Exit fullscreen mode

You can then launch the app in your browser by visiting http://localhost:3000/. You should see a nice “Hello World!” message.

The app will reload automatically after any changes you make. If you want to restart the app manually, use npm run start command instead.

Now we’re ready to start creating the store features.

Creating the NestJS ecommerce store product feature

In this section, we'll focus on product management. The store product feature will allow us to retrieve store products, add new ones, and edit or delete them.

Creating our product resources

Let’s start by creating the needed resources. To create them, run the following commands:

nest g module product
nest g service product --no-spec
nest g controller product --no-spec 
Enter fullscreen mode Exit fullscreen mode

The first command generates a product module and puts it in its own directory with the same name.

The next two commands generate service and controller files and import them automatically in the product module. The --no-spec argument tells Nest that we don't want to generate additional test files.

After running the above commands, we’ll get a new product directory containing the following files: product.module.ts, product.service.ts, and product.controller.ts.

Now we have a basic structure for the NestJS ecommerce store product feature. Before we move on, we need to set up our database.

Configuring the MongoDB database

As we are using MongoDB as a database, we'll need to install mongoose and @nestjs/mongoose packages.

npm install --save @nestjs/mongoose mongoose
Enter fullscreen mode Exit fullscreen mode

After the installation is complete, open app.module.ts and replace its content with the following:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; // 1.1 Import the mongoose module
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ProductModule } from './product/product.module'; // 2.1 Import the product module

@Module({
  imports: [
    MongooseModule.forRoot('mongodb://localhost/store'), // 1.2 Setup the database
    ProductModule, // 2.2 Add the product module
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Here’s what we did in the code above. Follow along using my numbered notes:

  • First, we imported the MongooseModule (1.1) and used it to set up a new store database (1.2)
  • Second, we imported the ProductModule (2.1) and added it to the imports array (2.2)

Our next step is to create a database schema for our product model.

Creating a product model schema

In the product directory, create a new schemas directory. Put a product.schema.ts file in the new directory with the following content:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type ProductDocument = Product & Document;

@Schema()
export class Product {
  @Prop()
  name: string;

  @Prop()
  description: string;

  @Prop()
  price: number;

  @Prop()
  category: string;
}

export const ProductSchema = SchemaFactory.createForClass(Product);
Enter fullscreen mode Exit fullscreen mode

The code above creates a schema for our product with name, description, price, and category properties.

Now edit the product.module.ts in the following manner:

import { Module } from '@nestjs/common';
import { ProductController } from './product.controller';
import { ProductService } from './product.service';
import { MongooseModule } from '@nestjs/mongoose'; // 1\. Import mongoose module
import { ProductSchema } from './schemas/product.schema'; // 2\. Import product schema

@Module({
  imports: [
    MongooseModule.forFeature([{ name: 'Product', schema: ProductSchema }]) // 3\. Setup the mongoose module to use the product schema
  ],
  controllers: [ProductController],
  providers: [ProductService]
})
export class ProductModule {}
Enter fullscreen mode Exit fullscreen mode

As you can see from my numbered notes, in the code above, we imported the MongooseModule (1) and ProductModule (2), then set the ProductSchema to be used for our product model (3).

Creating product DTO files

In addition to the product schema, we’ll also need two Data Transfer Object (DTO) files for our NestJS ecommerce app. A DTO file defines the data which will be received from a form submission, a search query, and so on.

We need one DTO for product creation and another for product filtering. Let’s create them now.

In the product directory, create a new dtos directory. Put a create-product.dto.ts file in this new directory with the following content:

export class CreateProductDTO {
  name: string;
  description: string;
  price: number;
  category: string;
}
Enter fullscreen mode Exit fullscreen mode

The above DTO defines a product object with the necessary properties for new product creation.

Then, in the same directory, create a filter-product.dto.ts file with the following content:

export class FilterProductDTO {
  search: string;
  category: string;
}
Enter fullscreen mode Exit fullscreen mode

This second DTO defines a filter object, which we’ll use to filter the store products by search query, category, or both.

Creating product service methods

All the prep work for this section is done. Now let’s create the actual code for product management.

Open the product.service.ts file and replace its content with the following:

import { Injectable } from '@nestjs/common';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import { Product, ProductDocument } from './schemas/product.schema';
import { CreateProductDTO } from './dtos/create-product.dto';
import { FilterProductDTO } from './dtos/filter-product.dto';

@Injectable()
export class ProductService {
  constructor(@InjectModel('Product') private readonly productModel: Model<ProductDocument>) { }

  async getFilteredProducts(filterProductDTO: FilterProductDTO): Promise<Product[]> {
    const { category, search } = filterProductDTO;
    let products = await this.getAllProducts();

    if (search) {
      products = products.filter(product => 
        product.name.includes(search) ||
        product.description.includes(search)
      );
    }

    if (category) {
      products = products.filter(product => product.category === category)
    }

    return products;
  }

  async getAllProducts(): Promise<Product[]> {
    const products = await this.productModel.find().exec();
    return products;
  }

  async getProduct(id: string): Promise<Product> {
    const product = await this.productModel.findById(id).exec();
    return product;
  }

  async addProduct(createProductDTO: CreateProductDTO): Promise<Product> {
    const newProduct = await this.productModel.create(createProductDTO);
    return newProduct.save();
  }

  async updateProduct(id: string, createProductDTO: CreateProductDTO): Promise<Product> {
    const updatedProduct = await this.productModel
      .findByIdAndUpdate(id, createProductDTO, { new: true });
    return updatedProduct;
  }

  async deleteProduct(id: string): Promise<any> {
    const deletedProduct = await this.productModel.findByIdAndRemove(id);
    return deletedProduct;
  }
}
Enter fullscreen mode Exit fullscreen mode

Let’s examine the code block above piece by piece.

First, let’s take a look at the section copied below:

@Injectable()
export class ProductService {
  constructor(@InjectModel('Product') private readonly productModel: Model<ProductDocument>) { }

}
Enter fullscreen mode Exit fullscreen mode

This code injects the needed dependencies (the product model) by using the @InjectModel decorator.

In the next section, we have two methods:

async getAllProducts(): Promise<Product[]> {
  const products = await this.productModel.find().exec();
  return products;
}

async getProduct(id: string): Promise<Product> {
  const product = await this.productModel.findById(id).exec();
  return product;
}
Enter fullscreen mode Exit fullscreen mode

The first method getAllProducts is for getting all products. The second method getProduct is for getting a single product. We use standard Mongoose methods to achieve these actions.

The method getFilteredProducts below returns filtered products:

async getFilteredProducts(filterProductDTO: FilterProductDTO): Promise<Product[]> {
  const { category, search } = filterProductDTO;
  let products = await this.getAllProducts();

  if (search) {
    products = products.filter(product => 
      product.name.includes(search) ||
      product.description.includes(search)
    );
  }

  if (category) {
    products = products.filter(product => product.category === category)
  }

  return products;
}
Enter fullscreen mode Exit fullscreen mode

Products can be filtered by search query, by category, or by both.

The next method addProduct below creates a new product:

async addProduct(createProductDTO: CreateProductDTO): Promise<Product> {
  const newProduct = await this.productModel.create(createProductDTO);
  return newProduct.save();
}
Enter fullscreen mode Exit fullscreen mode

addProduct achieves this by using the class from the create-product.dto.ts file and saving it to the database.

The final two methods are updateProduct and deleteProduct:

async updateProduct(id: string, createProductDTO: CreateProductDTO): Promise<Product> {
  const updatedProduct = await this.productModel
    .findByIdAndUpdate(id, createProductDTO, { new: true });
  return updatedProduct;
}

async deleteProduct(id: string): Promise<any> {
  const deletedProduct = await this.productModel.findByIdAndRemove(id);
  return deletedProduct;
}
Enter fullscreen mode Exit fullscreen mode

Using these methods, you can find a product by ID and either update it or remove it from the database.

Creating product controller methods

The final step for the product module is to create the API endpoints.

We’ll create the following API endpoints:

  • POST store/products/ — add new product
  • GET store/products/ — get all products
  • GET store/products/:id — get single product
  • PUT store/products/:id — edit single product
  • DELETE store/products/:id — remove single product

Open the product.controller.ts file and replace its content with the following:

import { Controller, Post, Get, Put, Delete, Body, Param, Query, NotFoundException } from '@nestjs/common';
import { ProductService } from './product.service';
import { CreateProductDTO } from './dtos/create-product.dto';
import { FilterProductDTO } from './dtos/filter-product.dto';

@Controller('store/products')
export class ProductController {
  constructor(private productService: ProductService) { }

  @Get('/')
  async getProducts(@Query() filterProductDTO: FilterProductDTO) {
    if (Object.keys(filterProductDTO).length) {
      const filteredProducts = await this.productService.getFilteredProducts(filterProductDTO);
      return filteredProducts;
    } else {
      const allProducts = await this.productService.getAllProducts();
      return allProducts;
    }
  }

  @Get('/:id')
  async getProduct(@Param('id') id: string) {
    const product = await this.productService.getProduct(id);
    if (!product) throw new NotFoundException('Product does not exist!');
    return product;
  }

  @Post('/')
  async addProduct(@Body() createProductDTO: CreateProductDTO) {
    const product = await this.productService.addProduct(createProductDTO);
    return product;
  }

  @Put('/:id')
  async updateProduct(@Param('id') id: string, @Body() createProductDTO: CreateProductDTO) {
    const product = await this.productService.updateProduct(id, createProductDTO);
    if (!product) throw new NotFoundException('Product does not exist!');
    return product;
  }

  @Delete('/:id')
  async deleteProduct(@Param('id') id: string) {
    const product = await this.productService.deleteProduct(id);
    if (!product) throw new NotFoundException('Product does not exist');
    return product;
  }
}
Enter fullscreen mode Exit fullscreen mode

NestJS provides a full set of JavaScript decorators to work with HTTP requests and responses (Get, Put, Body, Param, etc.), handle errors (NotFoundException), define controllers (Controller), and so on.

We imported the ones we need from @nestjs/common at the beginning of the file. We also import all the other files we’ve already created and we need: ProductService, CreateProductDTO, and FilterProductDTO.

From now on, I won’t explain imports in great detail. Most of them are pretty straightforward and self-explanatory. For more information about a particular class or component’s use, you can consult the documentation.

Let’s divide the rest of the code into smaller chunks.

First, we use @Controller decorator to set the the part of the URL which is shared by all endpoints:

@Controller('store/products')
export class ProductController {
  constructor(private productService: ProductService) { }
}
Enter fullscreen mode Exit fullscreen mode

We also inject the product service in the class constructor in the code above.

Next, we define the following endpoint by using the @Get decorator:

@Get('/')
async getProducts(@Query() filterProductDTO: FilterProductDTO) {
  if (Object.keys(filterProductDTO).length) {
    const filteredProducts = await this.productService.getFilteredProducts(filterProductDTO);
    return filteredProducts;
  } else {
    const allProducts = await this.productService.getAllProducts();
    return allProducts;
  }
}
Enter fullscreen mode Exit fullscreen mode

After defining the endpoint, we use @Query decorator in the getProducts() method and the object from filter-product.dto.ts to get the query parameters from a request.

If the query parameters from a request exist, we use getFilteredProduct() method from the product service. If there are no such parameters, we use the regular getAllProducts() method instead.

In the following endpoint, we use the @Body decorator to get the needed data from the request body and then pass it to the addProduct() method:

@Post('/')
async addProduct(@Body() createProductDTO: CreateProductDTO) {
  const product = await this.productService.addProduct(createProductDTO);
  return product;
}
Enter fullscreen mode Exit fullscreen mode

In the next endpoints, we use the @Param decorator to get the product ID from the URL:

@Get('/:id')
async getProduct(@Param('id') id: string) {
  const product = await this.productService.getProduct(id);
  if (!product) throw new NotFoundException('Product does not exist!');
  return product;
}

@Put('/:id')
async updateProduct(@Param('id') id: string, @Body() createProductDTO: CreateProductDTO) {
  const product = await this.productService.updateProduct(id, createProductDTO);
  if (!product) throw new NotFoundException('Product does not exist!');
  return product;
}

@Delete('/:id')
async deleteProduct(@Param('id') id: string) {
  const product = await this.productService.deleteProduct(id);
  if (!product) throw new NotFoundException('Product does not exist');
  return product;
}
Enter fullscreen mode Exit fullscreen mode

We then use the appropriate method from the product service to get, edit, or delete a product. If a product is not found, we use the NotFoundException to throw an error message.

Creating the user management feature

The next feature we need to create for our NestJS ecommerce app is the user management feature.

Generating our user management resources

For the user management feature, we’ll need only a module and a service. To create them, run the following:

nest g module user
nest g service user --no-spec 
Enter fullscreen mode Exit fullscreen mode

As with the previous feature, we’ll need a schema and DTO.

Creating a user schema and DTO

In the user directory generated by Nest, create a new schemas folder. Add a user.schema.ts file to this new folder with the following content:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
// import { Role } from 'src/auth/enums/role.enum';

export type UserDocument = User & Document;

@Schema()
export class User {
  @Prop()
  username: string;

  @Prop()
  email: string;

  @Prop()
  password: string;

/*
  @Prop()
  roles: Role[];
*/
}

export const UserSchema = SchemaFactory.createForClass(User);
Enter fullscreen mode Exit fullscreen mode

The commented code towards the end of the block will be used when we implement user authorization. I’ll tell you when to uncomment them later on in this tutorial.

Next, in the user directory, create a new dtos folder. Add a create-user-dto.ts file in this new folder with the following content:

export class CreateUserDTO {
  username: string;
  email: string;
  password: string;
  roles: string[];
}
Enter fullscreen mode Exit fullscreen mode

Configuring the resources

Open user.module.ts and set the schema in the same way as we did with the product feature:

import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { UserSchema } from './schemas/user.schema';
import { UserService } from './user.service';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: 'User', schema: UserSchema }])
  ],
  providers: [UserService],
  exports: [UserService]
})
export class UserModule {}
Enter fullscreen mode Exit fullscreen mode

In the code above, we are also exporting UserService so we can use it in the authentication service later on.

We’ll also need to install two additional packages: bcrypt and @types/bcrypt:

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

These packages enable us to keep the password saved, which we will work on in the next section.

Creating user service methods

Now let’s add the logic for the user management. Open the user.service.ts file and replace its content with the following:

import { Injectable } from '@nestjs/common';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import { User, UserDocument } from './schemas/user.schema';
import { CreateUserDTO } from './dtos/create-user.dto';
import * as bcrypt from 'bcrypt';

@Injectable()
export class UserService {
  constructor(@InjectModel('User') private readonly userModel: Model<UserDocument>) { }

  async addUser(createUserDTO: CreateUserDTO): Promise<User> {
    const newUser = await this.userModel.create(createUserDTO);
    newUser.password = await bcrypt.hash(newUser.password, 10);
    return newUser.save();
  }

  async findUser(username: string): Promise<User | undefined> {
    const user = await this.userModel.findOne({username: username});
    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode

We have added two methods in the code above. The addUser() method creates a new user, encrypts the new user’s password by using bcrypt.hash(), and then saves the user to the database.

The findUser() method finds a particular user by the username.

Creating user authentication and authorization

In this section, we’ll extend the user management feature in our NestJS ecommerce app by adding user authentication, which verifies the user’s identity, and user authorization, which defines what the user is allowed to do.

We’ll use the well-known Passport library, which provides a big variety of authenticating strategies. Let’s install the necessary packages:

npm install --save @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local
Enter fullscreen mode Exit fullscreen mode

In the code above, we installed the main passport package, the passport-local strategy (which implements a simple username and password authentication mechanism), and the Nest passport adapter. We also installed the types for passport-local.

We’ll also need to install also the dotenv package for managing environment variables:

npm install dotenv
Enter fullscreen mode Exit fullscreen mode

Create an .env file in the root directory and put the following code inside:

JWT_SECRET="topsecret"
Enter fullscreen mode Exit fullscreen mode

We’ll use this variable later on.

Generating our user authentication and authorization resources

As usual, let’s start by creating the needed resources for our auth feature:

nest g module auth
nest g service auth --no-spec 
nest g controller auth --no-spec 
Enter fullscreen mode Exit fullscreen mode

Creating user service methods

Open the auth.service.ts file and replace its content with the following:

import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(private readonly userService: UserService) {}

  async validateUser(username: string, password: string): Promise<any> {
    const user = await this.userService.findUser(username);
    const isPasswordMatch = await bcrypt.compare(
      password,
      user.password
    );
    if (user && isPasswordMatch) {
      return user;
    }
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The code above gives us a user validation method, which retrieves the user and verifies the user’s password.

Creating a local authentication strategy

In the auth directory, create a new strategies folder. Add a local.strategy.ts file in this new folder with the following content:

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super();
  }

  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}
Enter fullscreen mode Exit fullscreen mode

This code does two things.

First, it calls the super() method in the constructor. We can pass an options object here if we need to. We’ll go through an example later.

Second, we added a validate() method, which uses validateUser() from the auth service to verify the user.

Creating an authentication strategy with JWT

Now we’ll create a passport authentication strategy using JSON Web Tokens (JWT). This will return a JWT for logged users for use in subsequent calls to protected API endpoints.

Let’s install the necessary packages:

npm install --save @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt
Enter fullscreen mode Exit fullscreen mode

Next, in the strategies directory, create a jwt.strategy.ts file with the following content:

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import 'dotenv/config'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET,
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username, roles: payload.roles };
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we set an options object with the following properties:

  • jwtFromRequest tells the Passport module how JWT will be extracted from the request (in this case, as a bearer token)
  • ignoreExpiration set to false means the responsibility of ensuring that a JWT has not expired is delegated to the Passport module
  • secretOrKey is used to sign the token

The validate() method returns a payload, which is the JWT decoded as JSON. We then use this payload to return a user object with the necessary properties.

Now let’s modify the auth.service.ts file:

import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt'; // 1
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(private readonly userService: UserService, private readonly jwtService: JwtService) {} // 2

  async validateUser(username: string, password: string): Promise<any> {
    const user = await this.userService.findUser(username);
    const isPasswordMatch = await bcrypt.compare(
      password,
      user.password
    );
    if (user && isPasswordMatch) {
      return user;
    }
    return null;
  }

  async login(user: any) {
    const payload = { username: user.username, sub: user._id, roles: user.roles };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The code above is labeled so you can follow what we did:

  • Imported the JwtService (see //1)
  • Added JwtService to the constructor (see //2).

We then used the login() method to sign a JWT.

After all the changes we’ve made, we need to update the auth.module.ts in the following manner:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserModule } from 'src/user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';
import 'dotenv/config'

@Module({
  imports: [
    UserModule, 
    PassportModule,     
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: '3600s' },
    }),
  ],
  providers: [
    AuthService, 
    LocalStrategy, 
    JwtStrategy
  ],
  controllers: [AuthController],
})
export class AuthModule {}
Enter fullscreen mode Exit fullscreen mode

In the code above, we added UserModule, PassportModule, and JwtModule in the imports array.

We also used the register() method to provide the necessary options: the secret key and signOptions object, which set the token expiration to 3600s, or 1 hour.

Finally, we added LocalStrategy and JwtStrategy in the providers array.

Creating local and JWT guards

To use the strategies we’ve just created, we’ll need to create Guards.

In auth directory, create a new guards folder. Add a local.guard.ts file to this new folder with the following content:

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
Enter fullscreen mode Exit fullscreen mode

Also in the guards folder, create a jwt.guard.ts file with the following content:

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
Enter fullscreen mode Exit fullscreen mode

We’ll see how to use these guards in a minute. But first, let’s create the user authorization functionality.

Creating user roles management

To implement this feature in our NestJS ecommerce app, we’ll use role-based access control.

For this feature, we’ll need three files: role.enum.ts, roles.decorator.ts, and roles.guard.ts. Let’s start with the role.enum.ts file.

In the auth directory, create a new enums folder. Add a role.enum.ts file in this new folder with the following content:

export enum Role {
  User = 'user',
  Admin = 'admin',
}
Enter fullscreen mode Exit fullscreen mode

This represents the available roles for registered users.

Now you can go back to the user.schema.ts file we created earlier and uncomment the commented code.

Next, in the auth directory, create a new decorators folder. Add a roles.decorator.ts file in this new folder with the following content:

import { SetMetadata } from '@nestjs/common';
import { Role } from '../enums/role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
Enter fullscreen mode Exit fullscreen mode

In the code above, we used SetMetadata() to create the decorator.

Finally, in the guards directory, create a roles.guard.ts file with the following content:

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../enums/role.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles) {
      return true;
    }
    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some((role) => user.roles?.includes(role));
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we used the Reflector helper class to access the route's roles. We also switched the execution context to HTTP with switchToHttp() to get the user details using getRequest(). Finally, we returned the user’s roles.

Controller methods

Our last step in this section is to create the controller methods. Open the auth.controller.ts file and replace its content with the following:

import { Controller, Request, Get, Post, Body, UseGuards } from '@nestjs/common';
import { CreateUserDTO } from 'src/user/dtos/create-user.dto';
import { UserService } from 'src/user/user.service';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { Roles } from './decorators/roles.decorator';
import { Role } from './enums/role.enum';
import { RolesGuard } from './guards/roles.guard';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService, private userService: UserService) {}

  @Post('/register')
  async register(@Body() createUserDTO: CreateUserDTO) {
    const user = await this.userService.addUser(createUserDTO);
    return user;
  }

  @UseGuards(LocalAuthGuard)
  @Post('/login')
  async login(@Request() req) {
    return this.authService.login(req.user);
  }

  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.User)
  @Get('/user')
  getProfile(@Request() req) {
    return req.user;
  }

  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.Admin)
  @Get('/admin')
  getDashboard(@Request() req) {
    return req.user;
  }
}
Enter fullscreen mode Exit fullscreen mode

We have four endpoints in the code above:

  • POST auth/register is used to create a new user
  • POST auth/login is used to log in a registered user
    • To verify the user, we use the LocalAuthGuard
  • GET auth/user is used to access the user’s profile
    • We used JwtGuard to authenticate the user
    • We used RolesGuard plus @Roles decorator to provide the appropriate authorization depending on the user’s roles
  • GET auth/admin is used to access the admin dashboard
  • We also used JwtGuard and RolesGuard as done in the previous endpoint

Creating the store cart feature for our NestJS ecommerce app

The last feature we’ll add to our project is a basic cart functionality.

Creating our store cart resources

Let’s create the resources we need for this next section:

nest g module cart
nest g service cart --no-spec 
nest g controller cart --no-spec 
Enter fullscreen mode Exit fullscreen mode

Creating the schemas and DTOs

For the store cart feature, we’ll need two schemas: one describing the products in the cart, and one describing the cart itself.

As usual, in the cart directory, create a new schemas folder. Add a item.schema.ts file in this new folder with the following content:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, SchemaTypes } from 'mongoose';

export type ItemDocument = Item & Document;

@Schema()
export class Item {
  @Prop({ type: SchemaTypes.ObjectId, ref: 'Product' })
  productId: string;

  @Prop()
  name: string;

  @Prop()
  quantity: number;

  @Prop()
  price: number;

  @Prop()
  subTotalPrice: number; 
}

export const ItemSchema = SchemaFactory.createForClass(Item);
Enter fullscreen mode Exit fullscreen mode

In the code above, in the @Prop decorator for the productId property, we defined an object id schema type and added a reference to the product. This means that we will use the id of the product for the productId value.

The next schema is for the cart. In the schemas directory, create a cart.schema.ts file with the following content:

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, SchemaTypes } from 'mongoose';
import { Item } from './item.schema';

export type CartDocument = Cart & Document;

@Schema()
export class Cart {
  @Prop({ type: SchemaTypes.ObjectId, ref: 'User' })
  userId: string;

  @Prop()
  items: Item[];

  @Prop()
  totalPrice: number; 
}

export const CartSchema = SchemaFactory.createForClass(Cart);
Enter fullscreen mode Exit fullscreen mode

Here, we use the same technique for the userId property which will get as a value the user’s id. For the items property we use the our Item schema to define an array of items with type of Item.

And lastly, let’s create the item DTO. In the user directory, create a new dtos folder and add an item.dto.ts file with the following content:

export class ItemDTO {
  productId: string;
  name: string;
  quantity: number;
  price: number;
}
Enter fullscreen mode Exit fullscreen mode

Configuring the cart module

Before we move to the business logic, we need to add the cart schema to the cart module. Open the cart.module.ts file and configure it to use the cart schema as follows:

import { Module } from '@nestjs/common';
import { CartController } from './cart.controller';
import { CartService } from './cart.service';
import { MongooseModule } from '@nestjs/mongoose';
import { CartSchema } from './schemas/cart.schema';

@Module({
  imports: [
    MongooseModule.forFeature([{ name: 'Cart', schema: CartSchema }])
  ],
  controllers: [CartController],
  providers: [CartService]
})
export class CartModule {}
Enter fullscreen mode Exit fullscreen mode

Creating cart service methods

Now let’s create the cart management logic. Open the cart.service.ts file and replace its content with the following:

import { Injectable } from '@nestjs/common';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import { Cart, CartDocument } from './schemas/cart.schema';
import { ItemDTO } from './dtos/item.dto';

@Injectable()
export class CartService {
  constructor(@InjectModel('Cart') private readonly cartModel: Model<CartDocument>) { }

  async createCart(userId: string, itemDTO: ItemDTO, subTotalPrice: number, totalPrice: number): Promise<Cart> {
    const newCart = await this.cartModel.create({
      userId,
      items: [{ ...itemDTO, subTotalPrice }],
      totalPrice
    });
    return newCart;
  }

  async getCart(userId: string): Promise<CartDocument> {
    const cart = await this.cartModel.findOne({ userId });
    return cart;
  }

  async deleteCart(userId: string): Promise<Cart> {
    const deletedCart = await this.cartModel.findOneAndRemove({ userId });
    return deletedCart;
  }

  private recalculateCart(cart: CartDocument) {
    cart.totalPrice = 0;
    cart.items.forEach(item => {
      cart.totalPrice += (item.quantity * item.price);
    })
  }

  async addItemToCart(userId: string, itemDTO: ItemDTO): Promise<Cart> {
    const { productId, quantity, price } = itemDTO;
    const subTotalPrice = quantity * price;

    const cart = await this.getCart(userId);

    if (cart) {
      const itemIndex = cart.items.findIndex((item) => item.productId == productId);

      if (itemIndex > -1) {
        let item = cart.items[itemIndex];
        item.quantity = Number(item.quantity) + Number(quantity);
        item.subTotalPrice = item.quantity * item.price;

        cart.items[itemIndex] = item;
        this.recalculateCart(cart);
        return cart.save();
      } else {
        cart.items.push({ ...itemDTO, subTotalPrice });
        this.recalculateCart(cart);
        return cart.save();
      }
    } else {
      const newCart = await this.createCart(userId, itemDTO, subTotalPrice, price);
      return newCart;
    }
  }

  async removeItemFromCart(userId: string, productId: string): Promise<any> {
    const cart = await this.getCart(userId);

    const itemIndex = cart.items.findIndex((item) => item.productId == productId);

    if (itemIndex > -1) {
      cart.items.splice(itemIndex, 1);
      return cart.save();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

There are many methods here. Let’s examine them one by one.

The first one is for creating a new cart for the current user:

async createCart(userId: string, itemDTO: ItemDTO, subTotalPrice: number, totalPrice: number): Promise<Cart> {
  const newCart = await this.cartModel.create({
    userId,
    items: [{ ...itemDTO, subTotalPrice }],
    totalPrice
  });
  return newCart;
}
Enter fullscreen mode Exit fullscreen mode

The next two methods are for getting or deleting a particular user’s cart:

async getCart(userId: string): Promise<CartDocument> {
  const cart = await this.cartModel.findOne({ userId });
  return cart;
}

async deleteCart(userId: string): Promise<Cart> {
  const deletedCart = await this.cartModel.findOneAndRemove({ userId });
  return deletedCart;
}
Enter fullscreen mode Exit fullscreen mode

The next method is for recalculating the cart total when an item is added or removed, or when an item’s quantity is changed:

private recalculateCart(cart: CartDocument) {
  cart.totalPrice = 0;
  cart.items.forEach(item => {
    cart.totalPrice += (item.quantity * item.price);
  })
}
Enter fullscreen mode Exit fullscreen mode

The next method is for adding items to the cart:

async addItemToCart(userId: string, itemDTO: ItemDTO): Promise<Cart> {
  const { productId, quantity, price } = itemDTO;
  const subTotalPrice = quantity * price;

  const cart = await this.getCart(userId);

  if (cart) {
    const itemIndex = cart.items.findIndex((item) => item.productId == productId);

    if (itemIndex > -1) {
      let item = cart.items[itemIndex];
      item.quantity = Number(item.quantity) + Number(quantity);
      item.subTotalPrice = item.quantity * item.price;

      cart.items[itemIndex] = item;
      this.recalculateCart(cart);
      return cart.save();
    } else {
      cart.items.push({ ...itemDTO, subTotalPrice });
      this.recalculateCart(cart);
      return cart.save();
    }
  } else {
    const newCart = await this.createCart(userId, itemDTO, subTotalPrice, price);
    return newCart;
  }
}
Enter fullscreen mode Exit fullscreen mode

In the method above, if the cart exists, there are two options:

  1. The product exists, so we need to update its quantity and subtotal price
  2. The product doesn’t exist, so we need to add it

Either way, we need to run the recalculateCart() method to update the cart appropriately. If the cart doesn’t exist, we need to create a new one.

The last method is for removing an item from the cart:

async removeItemFromCart(userId: string, productId: string): Promise<any> {
  const cart = await this.getCart(userId);

  const itemIndex = cart.items.findIndex((item) => item.productId == productId);

  if (itemIndex > -1) {
    cart.items.splice(itemIndex, 1);
    this.recalculateCart(cart);
    return cart.save();
  }
}
Enter fullscreen mode Exit fullscreen mode

Similarly to the previous method, in the method above, we run recalculateCart() to update the cart correctly after an item is removed.

Creating cart controller methods

Our final step to finish this NestJS ecommerce app project is to add the cart controller methods.

Open cart.controller.ts file and replace its content with the following:

import { Controller, Post, Body, Request, UseGuards, Delete, NotFoundException, Param } from '@nestjs/common';
import { Roles } from 'src/auth/decorators/roles.decorator';
import { Role } from 'src/auth/enums/role.enum';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RolesGuard } from 'src/auth/guards/roles.guard';
import { CartService } from './cart.service';
import { ItemDTO } from './dtos/item.dto';

@Controller('cart')
export class CartController {
  constructor(private cartService: CartService) { }

  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.User)
  @Post('/')
  async addItemToCart(@Request() req, @Body() itemDTO: ItemDTO) {
    const userId = req.user.userId;
    const cart = await this.cartService.addItemToCart(userId, itemDTO);
    return cart;
  }

  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.User)
  @Delete('/')
  async removeItemFromCart(@Request() req, @Body() { productId }) {
    const userId = req.user.userId;
    const cart = await this.cartService.removeItemFromCart(userId, productId);
    if (!cart) throw new NotFoundException('Item does not exist');
    return cart;
  }

  @UseGuards(JwtAuthGuard, RolesGuard)
  @Roles(Role.User)
  @Delete('/:id')
  async deleteCart(@Param('id') userId: string) {
    const cart = await this.cartService.deleteCart(userId);
    if (!cart) throw new NotFoundException('Cart does not exist');
    return cart;
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we used @UseGuards and @Roles decorators for the three methods. This instructs the app that a customer must be logged in and must have a user role assigned to add or remove products.

That’s it. If you have followed along correctly, you should have a basic but fully functional NestJS eccomerce app.

Conclusion

Phew! This was a pretty long ride. I hope you’ve enjoyed and learned something new about NestJS.

Despite the detailed explanations needed to explain each step of building this NestJS ecommerce app example, it is pretty basic and can be extended to include even more features. Here are some ideas you can try:

As you can see, NestJS is a powerful and flexible server-side framework that can give you a robust and scalable structure for your next projects. If you want to learn more, dive into the official Nest documentation and start building great apps.


LogRocket: See the technical and UX reasons for why users don’t complete a step in your ecommerce flow.

LogRocket Sign Up

LogRocket is like a DVR for web and mobile apps and websites, recording literally everything that happens on your ecommerce app. Instead of guessing why users don’t convert, LogRocket proactively surfaces the root cause of issues that are preventing conversion in your funnel, such as JavaScript errors or dead clicks. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Start proactively monitoring your ecommerce apps — try for free.

Top comments (1)

Collapse
 
mwaqarulamin profile image
M. Waqar ul Amin

Very helpful and detailed tutorial. Thanks.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.