How To Build A Twitter Clone With NestJS, Prisma And React ( Part 1 )


Overview

In this tutorial we are going to explore in details the process of building a Twitter clone as a complete web application, which will consist of a React single page application, backed by an API server built with NestJS and Prisma.

The features we are going to implement are:

  • Read tweets feed
  • Post a tweet
  • Visit users' profile
  • Follow other users
  • Likes & replies

Requirements

  • Basic web APIs & HTTP knowledge
  • NodeJS & npm
  • Typescript ( and Javascript )
  • PostgreSQL basic knowledge
  • React basics ( with hooks )

Setup

We need a Postgres instance with a brand new database to store our application data. Once you installed Postgres ( you can use Postgres App, Docker or the official installer ) you have to create a new database. Just open up your favorite terminal client and run psql to start a Postgres shell session. You can now create the new database simply running the corresponding SQL command: CREATE DATABASE "twitter";.

Next we need to install the NestJS CLI:

npm i -g @nestjs/cli

At the time of writing, the last Nest CLI version is 7.5.1.

Now we can use it to scaffold our project inside a twitter-clone folder. Feel free to choose your favorite package manager when prompted, I'm going to use npm.

mkdir twitter-clone && cd twitter-clone
nest new twitter-api

Let's open up your favorite editor and look at the project structure.

We can see a bunch of configuration files, a test folder, and finally, an src folder where all the code we'll write will live.

Let's open up the main.ts file, which is the entry point of our application.

Here we can immediately notice the only declared function, the bootstrap function, which instantiates our Nest application and makes it listen for requests on port 3000.

To test this out let's start our server:

npm run start:dev

Every time a file changes in our project directory, the Nest CLI will take care of restarting the server.

Open up your favorite HTTP client ( I'm going to use HTTPie, which is a nice curl alternative, but you can also use a GUI based one such as Postman ) and try to send a request to our server.

http localhost:3000

We should see Hello World! as the response. Our server is working!

Let's now take a look behind the scenes.

NestJS Fundamentals

In the bootstrap function we can see how our Nest application is instantiated from the AppModule class by the create factory function. NestJS promotes a modular application structure, which means that we are supposed to organize every "feature", with its own set of capabilities, within its own module.

The root module of our application is the AppModule. Let's open up the app.module.ts file.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

As you can see a module is just a class with a @Module decorator ( if you're not familiar with the concept of decorators I strongly recommend reading the dedicated page in the Typescript handbook since we will frequently use them throughout this tutorial ). The @Module decorator takes a single object whose properties are:

  • controllers: a list of classes in charge of handling http requests.
  • providers: a list of classes ( or services ) which encapsulate business logic. It could consist of module-specific features or global utilities, or even external classes exported by third-party packages.
  • imports: a list of modules imported by this module. This allows the module to take advantage of other modules' functionalities. We'll see and discuss this feature later on.

Let's now take a look at the AppController class.

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

The first thing we can see is the Controller decorator on top of the class declaration, which tells Nest that we want to use this class to handle http requests. The second thing is the presence of a parameter in the class constructor, whose type is at the moment the only provider in this module, the AppService class. NestJS will take care of injecting an instance of this class every time the controller will need it ( more on this later ), thanks to its powerful dependency injection system.

Let's now focus on the getHello method. The Get decorator is a way to map this method to an endpoint and an HTTP verb. Sending a GET request to localhost:3000/ it will be handled by this method. To specify a different path we can add a string parameter like this:

@Get('hello')

This way the mapped endpoint will now be localhost:3000/hello, while a request to the base path / would trigger a 404 HTTP error because there is no method to handle it.

We can also add a string parameter to the Controller decorator to add a path prefix to all methods. More on controllers and endpoints mapping in the dedicated page in the official NestJS documentation.

As we can see the only thing this method is doing is calling the getHello method of the AppService class. This is because controllers are not supposed to hold business logic, the same way services are not supposed to handle endpoints mapping, following the single-responsibility principle.

Let's now take a look at the last piece of the puzzle, the AppService class.

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

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

The most important thing here is the Injectable decorator. This decorator tells NestJS that this service is going to be used as a provider ( for example by the AppController ), thus we need it to be handled by the dependency injection system.

The getHello method is just returning the Hello World! string, which now we know where it was coming from.

Let's now begin with our features implementation.

The users module

The first thing we are going to implement in our application is user management.

Let's generate the users module with the Nest CLI:

nest generate module users

This will generate a new users folder in the src directory, which will contain a users.module.ts file with an empty module declaration.

Let's add a controller:

nest generate controller users

The Nest CLI will not only generate the controller file and class, but it will also add the new controller to the controllers list of the module in the file with the same path and prefix ( users/users.module.ts ).

The new controller will also have the users string as a path parameter in the Controller decorator because Nest assumes every endpoint mapped by this class will begin with this prefix.

Along with this file Nest will generate the users.controller.spec.ts file. A file like this will be generated for almost every generated file, and this is where we are supposed to write our tests. Let's leave it aside for now.

Let's now generate the users service:

nest generate service users

This time Nest will generate a UsersService class within the users module with the Injectable decorator on top and will also add it to the providers parameter of the users module.

To implement our business logic we now need to setup Prisma.

Prisma setup

Prisma is a relatively new data access framework for NodeJS written in Typescript, which makes it a particular fit for our project. It takes care of migrations ( this is an experimental feature at the time of this tutorial ) and it generates a complete, type-safe Typescript client to access and manage our data.

Let's install the Prisma CLI and run the init command.

npm install @prisma/cli --save-dev
npx prisma init

At the time of this tutorial, the last Prisma version is 2.6.2.

Prisma will use the DATABASE_URL environment variable declared in the generated prisma/.env file, so let's adapt it to match our database connection string. In my case, it looks like this ( those are the default parameters if you installed Postgres through the Postgres App ):

DATABASE_URL="postgresql://postgres:secret@localhost:5432/twitter?schema=public"

Let's now add a new model to the Prisma data model in the prisma/schema.prisma file.

Our user table will have a username column as the primary key since it will be unique for every user, and also a password and a display name.

model User {
  username    String @id
  password    String
  displayName String
}

To generate and apply the migration run the following commands:

npx prisma migrate save --name users --experimental
npx prisma migrate up --experimental

If everything goes well a new User table will be created in your database.

We can now generate the Prisma client with the following command:

npm install @prisma/client

This will automatically tell Prisma to generate the client in the node_modules/.prisma/client directory, and it will be referenced and exported by the @prisma/client package to be imported by us in our project. Specifically, it generates a PrismaClient class, which we'll be using every time we need to access our database.

To use Prisma in our application we might think to import the client directly in our services, but that would be the wrong way to go. We definitely want to take advantage of the Nest dependency injection system, to let the framework handle instantiation and injection when it needs to, keeping our application fast and our project structure clean and well organized.

This is another perfect use case for providers. All we have to do is to write a class that will extend the generated PrismaClient class and makes it Injectable.

// src/prisma.service.ts

import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

Our PrismaService also need to call the $connect method when the service is instantiated by the framework to connect to the database and the $disconnect method on application shutdown. To do that our class needs to implement the onModuleInit and onModuleDestroy methods declared in the interfaces with the same name, which will be called by the framework at the right moment.

Now that we have our prisma service we can import it in our users module to be used in the users service.

// users.module.ts

// ..
import { PrismaService } from '../prisma.service';

@Module({
  controllers: [UsersController],
  providers: [UsersService, PrismaService],
})
// ...

Our first endpoints

Let's now implement the following endpoints:

  • GET /users/:username: get a user by his username
  • POST /users: create a user

We can easily write the logic for the first one in our UsersService:

// users.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { User } from '@prisma/client';
import { PrismaService } from '../prisma.service';

@Injectable()
export class UsersService {
  constructor(private db: PrismaService) {}

  async findOne(username: string): Promise<User> {
    const user = await this.db.user.findOne({
      where: { username },
    });

    if (!user) {
      throw new NotFoundException();
    }

    delete user.password;
    return user;
  }
}

Let's break this down:

  • We added the PrismaService as a constructor parameter to let the framework inject an instance of it on application startup. I called it db for brevity since we are going to use it a lot.
  • Instead of declaring our own user type, we used the User type generated by Prisma as the function return type to avoid code repetitions.
  • If a user with the provided username does not exist, we simply throw a NotFoundException provided by Nest, which will be caught by the framework and result in an HTTP 404 error ( more on this feature in the official Nest documentation at this page ).
  • Finally, we do not want to send to the client the user's password, therefore we need to remove it from the user object.

Let's now move on to the create method.

There is one important thing to consider here: we do not want to store users' passwords in plain text in the database. We want to make things very difficult for anyone who manage to access our data, and that's exactly what hashing functions, and specifically the bcrypt library, are made for. To better understand how does bcrypt work and how it manages to keep our passwords safe you can read this article.

What you need to know right now is that we'll use bcrypt to produce an hashed string which we'll store in the database instead of the password. In the same way, when a user tries to log in, we need to compare the password he'll send to the server with the stored hash using the same library.

Let's install bcrypt and its types, and then use it to implement our create method.

npm install bcrypt
npm install @types/bcrypt --save-dev
// users.service.ts

import {
  // ...
  ConflictException,
} from '@nestjs/common';
import { User, UserCreateInput } from '@prisma/client';
import { PrismaService } from '../prisma.service';
import bcrypt from 'bcrypt';

@Injectable()
export class UsersService {
  // ...

  async create(data: UserCreateInput): Promise<User> {
    const existing = await this.db.user.findOne({
      where: { username: data.username },
    });

    if (existing) {
      throw new ConflictException('username_already_exists');
    }

    // the second argument ( 10 ) is just a "cost factor".
    // the higher the cost factor, the more difficult is brute-forcing
    const hashedPassword = await bcrypt.hash(data.password, 10);

    const user = await this.db.user.create({
      data: {
        ...data,
        password: hashedPassword,
      },
    });

    delete user.password;
    return user;
  }
}

A few things to notice here:

  • We used the UserCreateInput generated by Prisma as the argument type.
  • We need to check if a user with the provided username exists, and if that's the case we throw a ConflictException, which corresponds to the 409 HTTP status code.
  • As well as for the findOne method, we need to remove the password from the user object to avoid to send it to the client.

We can now use these methods in our controller and implement endpoints mapping.

To handle incoming data in the POST /create request body we need to declare a DTO class, which will live in the users/users.dto.ts file.

// users/users.dto.ts

export class CreateUserDto {
  username: string;
  password: string;
  displayName: string;
}
import { Body, Controller, Get, Post, Param } from '@nestjs/common';
import { User } from '@prisma/client';
import { CreateUserDto } from './users.dto';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private service: UsersService) {}

  @Get(':username')
  findOne(@Param('username') username: string): Promise<User> {
    return this.service.findOne(username);
  }

  @Post()
  create(@Body() data: CreateUserDto): Promise<User> {
    return this.service.create(data);
  }
}

Let's see what we did here:

  • The Controller decorator has one string parameter, users, which means that every endpoint in this controller will have a users base path.
  • The Get decorator on top of the findOne method has a :username parameter. That means this method will handle every GET request to a path that includes some dynamic part after the users/ prefix, such as users/jack or users/xyz. The dynamic part can be accessed in the method using the Param decorator.
  • The create method uses the Post decorator because it is supposed to handle only POST requests. It also uses the Body decorator to inject the request body into the data parameter the same way we injected the username parameter in the findOne method with the Param decorator. The type of the data parameter is, of course, our CreateUserDto class.

There are some pretty evident security flaws in this implementation. The first one is that a user might send a POST request to create a user with invalid data, maybe an empty username or an empty object.

To fix these we can take advantage of a powerful feature Nest provides us: pipes.

Pipes are simply classes that operate on the arguments of a controller's methods before they get passed to the handler function.

Data validation is the most typical use case for pipes, that's why Nest provides a built-in ValidationPipe, which we can use to validate our data along with the class-validator and class-transformer libraries. Let's install them.

npm install class-transformer class-validator

Next, we need to set up the ValidationPipe in the main.ts file.

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // validation pipe setup
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
      forbidNonWhitelisted: true,
    })
  );

  await app.listen(3000);
}
bootstrap();

We use the app.useGlobalPipes method to essentially tell Nest to validate incoming data for every request, with the following options:

  • transform: true tells the pipe to transform every data field to a value of the desired type. This way even if a string field is sent as a number it will always be a string.
  • whitelist: true and forbidNonWhitelisted: true tell the pipe to throw an HTTP 400 error ( Bad Request ) if there are any fields in the request body which are not specified in the DTO class.

To instruct our ValidationPipe on how to validate our CreateUserDto data fields we are going to use some decorators provided by the class-validator library.

import { IsString, Length } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @Length(3, 30)
  username: string;

  @IsString()
  @Length(6, 30)
  password: string;

  @IsString()
  @Length(1, 50)
  displayName: string;
}

As simple as it looks, we want every field to be of type string and to respect some length constraints.

Our implementation is now complete, let's test this out:

http POST localhost:3000/users unknownField="xyz"
HTTP/1.1 400 Bad Request

{
  "error": "Bad Request",
  "message": [
    "property unknownField should not exist",
    "username must be longer than or equal to 6 characters",
    "username must be a string",
    "password must be longer than or equal to 6 characters",
    "password must be a string",
    "displayName must be longer than or equal to 1 characters",
    "displayName must be a string"
  ],
  "statusCode": 400
}
http POST localhost:3000/users username="jack" password="123456" displayName="Jack"
HTTP/1.1 201 Created

{
  "displayName": "Jack",
  "password": "123456",
  "username": "jack"
}
http localhost:3000/users/jack
HTTP/1.1 200 OK

{
  "displayName": "Jack",
  "password": "123456",
  "username": "jack"
}

Looks like everything works as expected.

In the next part of this tutorial we'll take care of a crucial aspect of every web application: authentication.

Did you find this article valuable?

Support Dario Ielardi by becoming a sponsor. Any amount is appreciated!