How To Build A Twitter Clone With NestJS, Prisma And React ( Part 1 )
- Part 1: Setup & first steps ( this article )
- Part 2: Authentication
- Part 3 Coming Soon
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 usernamePOST /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 itdb
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 ausers
base path. - The
Get
decorator on top of thefindOne
method has a:username
parameter. That means this method will handle every GET request to a path that includes some dynamic part after theusers/
prefix, such asusers/jack
orusers/xyz
. The dynamic part can be accessed in the method using theParam
decorator. - The
create
method uses thePost
decorator because it is supposed to handle only POST requests. It also uses theBody
decorator to inject the request body into thedata
parameter the same way we injected theusername
parameter in thefindOne
method with theParam
decorator. The type of thedata
parameter is, of course, ourCreateUserDto
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 astring
field is sent as a number it will always be astring
.whitelist: true
andforbidNonWhitelisted: 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.