The best node web framework (part 3)
This article is the third part of the series Creating the modern developer stack template. I definitely recommend reading the first one before this to get more context.
Introduction
Hello folks,
Today we will try to identify the best framework to build a REST API that we will use to interact with our frontend. There exist quite a few web frameworks for the node.js ecosystem. I will be covering the top 3 open source projects based on this query. I will be looking for ease of use; so how easy it is to get started with the framework along with the quality of the documentation. Popularity is another big factor that goes hands down with the ease of use as you will be able to find solutions to all the common problems a newbie can have. Performance is also something to keep in mind. Still, it will have lesser importance than the 2 previous points as I believe most web frameworks nowadays will provide decent enough performance for most of the use cases. Thus, you don't want to sacrifice development speed for some very marginal performance gain. Code readability will also be taken into consideration, and I will lay out an example of creating a simple GET route for all of the frameworks for comparison.
Comparisons
socket.io
Our most popular contender isn't your typical restful api as it uses 2-way communication between the client and the server.
This allows you to have real-time applications. With great power comes great responsibilities. Providing a completely real-time web app is a tough challenge. Although this adds great value for the end-user, you need skilled and experienced developers to pull this off. From network connectivity issues to testing difficulties to scaling, you will surely get many bumps in the road. If you want to open your API to other developers, they will have a harder time consuming it rather than a regular REST API. If your app requires everything to be fully real-time, socket.io is definitely a good option with its wide usage and good documentation.
Example:
socket.on('/todos', () => {
const todos = { CODE_TO_RETRIEVE_TODOS }
io.emit(todos)
});
Express
Even though it has fewer stars than socket.io on GitHub, this is probably the most widely used framework to build APIs. It describes itself as a fast, unopinionated, minimalist web framework for node. It supports robust routing functionalities, fast performance, support for redirection and caching. Interestingly enough, even though express claims itself that it is fast, it's actually one of the slowest out there:
Documentation could definitely be improved, but you should be able to find everything you need since it's one of the most popular. Anything missing from the documentation should already be covered elsewhere.
Example:
app.get('/todos', function (req, res) {
const todos = { CODE_TO_RETRIEVE_TODOS }
res.send(todos)
})
Nest
This framework shouldn't be confused with the company nest that google acquired in 2014. This is a web framework, and it describes itself as a framework for building efficient, scalable Node.js server-side applications. It uses modern JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Reactive Programming). Under the hood, Nest makes use of Express and provides compatibility with a wide range of other libraries, like, e.g. Fastify, allowing for easy use of the myriad third-party plugins available. Testing is made really easy as it enforces great patterns like dependency injection. Nest uses the latest TypeScript features async-await and decorators. It is growing very rapidly, and the community absolutely loves it. This might be related to the main author love of cats. The documentation is the absolute best compared to the previous ones. You get so much information on everything you could think of. It is well written, concise and accurate.
Example:
import { Get, Controller } from '@nestjs/common';
import { TodosService } from './todos.service';
@Controller()
export class TodosController {
constructor(private readonly todosService: todosService) {}
@Get()
render() {
const todos = this.todosService.getTodos();
return todos;
}
}
The winner
For all of its future proof functionalities, it's highly abstracted architecture, it's amazing documentation, and it's rapid growth, Nest is our clear winner. The above-provided examples aren't very complete. Thus I will implement one on my open-source template. It will be a backend for a simple todo application with CRUD endpoints. A front-end will be built in the following article.
Implementation in Stator
Since I'm using NX, I can easily generate the scaffolding required for node backend using nest. We first need to install the plugin:
npm install -D @nrwl/nest
We then generate the application:
nx generate @nrwl/nest:application api
Our apps folder now contains the newly generated API:
As we have seen in the previous benchmark, express isn't exactly fast. Thankfully nest provides support for fastify. We need to install the plugin:
npm i --save @nestjs/platform-fastify
We then update our main.ts
file to this in order to use fastify:
import { Logger } from "@nestjs/common"
import { NestFactory } from "@nestjs/core"
import { AppModule } from "./app/app.module"
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify"
async function bootstrap() {
const globalPrefix = "api"
const port = parseInt(process.env.PORT) || 3333
const fastifyOptions = { logger: true }
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(fastifyOptions))
app.setGlobalPrefix(globalPrefix)
await app.listen(port)
}
bootstrap()
Let's install the nest CLI to help us generate our todos
controller:
npm install -g @nestjs/cli
Generate the controller:
nest g controller todos
To make our life easier handling the CRUD operations, we will be using the amazing plugin @nextjsx/crud, along with the mongo driver:
npm i @nestjsx/crud class-transformer class-validator @nestjsx/crud-typeorm @nestjs/typeorm typeorm mongo
In the src
, create a file todo.entity.ts
:
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
@Entity()
export class Todo {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
}
Create the controller:
import { Controller } from "@nestjs/common"
import { Crud, CrudController } from "@nestjsx/crud"
import { Todo } from "./todo.entity"
import { TodosService } from "./todos.service"
@Crud({ model: { type: Todo } })
@Controller("todos")
export class TodosController implements CrudController<Todo> {
constructor(public service: TodosService) {}
}
Create the service:
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { TypeOrmCrudService } from "@nestjsx/crud-typeorm";
import { Todo } from "./todo.entity"
@Injectable()
export class TodosService extends TypeOrmCrudService<Todo> {
constructor(@InjectRepository(Todo) repo) {
super(repo);
}
}
Link everything together with the module:
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { TodosService } from "./todos.service";
import { TodosController } from "./todos.controller";
import { Todo } from "./todo.entity"
@Module({
imports: [TypeOrmModule.forFeature([Todo])],
providers: [TodosService],
exports: [TodosService],
controllers: [TodosController],
})
export class TodosModule {}
Now in our app.module.ts
, let's add the connection to mongo and make our API aware of our todos capabilities, update the @Module
property with:
@Module({
imports: [
TypeOrmModule.forRoot({
type: "mongodb",
host: "127.0.0.1",
port: 27017,
database: "stator",
username: "stator",
password: "secret",
entities: [Todo],
synchronize: true
}),
TodosModule
],
controllers: [AppController],
providers: [AppService],
})
Now it would be great to see what endpoints are available. Let's generate some API documentation using swagger:
npm install --save @nestjs/swagger fastify-swagger
Now add this do to main.ts
:
const swaggerOptions = new DocumentBuilder()
.setTitle('Todos')
.setDescription('The todos API description')
.setVersion('1.0')
.addTag('todos')
.build();
const document = SwaggerModule.createDocument(app, swaggerOptions);
SwaggerModule.setup('documentation', app, document);
If you go to `localhost:3333/documentation, you will see this:
Surely enough, swagger UI is nice, but definitely, redoc does look better. Let's see how we can implement this.
Create a index.html
file in src/assets
directory:
<!DOCTYPE html>
<html>
<head>
<title>ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet" />
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url="http://127.0.0.1:3333/documentation/json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"> </script>
</body>
</html>
Now in your app.module.ts
add this to your @Module
import property:
ServeStaticModule.forRoot({
rootPath: `${__dirname}/assets`,
exclude: ['/api*'],
}),
If you access localhost:3333
with any browser, you will see the new beautiful interface:
Now that we have created all of this, let's make sure it works by writing tests. First off, install the testing package that nest provides:
npm i --save-dev @nestjs/testing
Create todos.e2e.spec.ts
which will contain our end to end tests:
describe("Todos", () => {
let app: INestApplication
let repository: Repository<Todo>
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: "mongodb",
host: "127.0.0.1",
port: 27017,
database: "stator-test",
username: "stator",
password: "secret",
entities: [Todo],
synchronize: true,
}),
TodosModule,
],
}).compile()
app = module.createNestApplication()
await app.init()
repository = module.get("TodoRepository")
})
afterEach(async () => {
await repository.delete({})
})
afterAll(async () => {
await app.close()
})
describe("GET /todos", () => {
it("should return an array of todos", async () => {
await repository.save([{ text: "test-name-0" }, { text: "test-name-1" }])
const { body } = await supertest
.agent(app.getHttpServer())
.get("/todos")
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200)
expect(body).toEqual([
{ id: expect.any(Number), text: "test-name-0" },
{ id: expect.any(Number), text: "test-name-1" },
])
})
it("should return a single todo", async () => {
const todo = await repository.save({ text: "test-name-0" })
const { body } = await supertest
.agent(app.getHttpServer())
.get(`/todos/${todo.id}`)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200)
expect(body).toEqual({ id: todo.id, text: "test-name-0" })
})
it("should create one todo", async () => {
const todo = { text: "test-name-0" }
const { body } = await supertest
.agent(app.getHttpServer())
.post("/todos")
.send(todo)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(201)
expect(body).toEqual({ id: expect.any(Number), text: "test-name-0" })
})
it("should create multiple todos", async () => {
const todos = [{ text: "test-name-0" }, { text: "test-name-1" }]
const { body } = await supertest
.agent(app.getHttpServer())
.post("/todos/bulk")
.send({ bulk: todos })
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(201)
expect(body).toEqual([
{ id: expect.any(Number), text: "test-name-0" },
{ id: expect.any(Number), text: "test-name-1" },
])
})
it("should update the name of a todo", async () => {
const todo = await repository.save({ text: "test-name-0" })
const { body } = await supertest
.agent(app.getHttpServer())
.put(`/todos/${todo.id}`)
.send({ text: "updated-name" })
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200)
expect(body).toEqual({ id: expect.any(Number), text: "updated-name" })
})
it("should delete one todo", async () => {
const todo = await repository.save({ text: "test-name-0" })
await supertest
.agent(app.getHttpServer())
.delete(`/todos/${todo.id}`)
.set("Accept", "application/json")
.expect(200)
const missingTodo = await repository.findOne({ id: todo.id })
expect(missingTodo).toBe(undefined)
})
})
})
When we run the tests, we have a very unpleasant surprise that it simply doesn't work. That is because typeorm
doesn't play well with MongoDB. After trying to find solutions, I concluded the best one was to switch the database completely. Since typeorm
supports mainly SQL databases, I've decided to go with Postgres
as it supports NoSQL.
Here is the docker-compose.yml
file:
version: "3.6"
services:
database:
image: postgres:latest
container_name: stator-postgres
ports:
- 5433:5432
restart: always
command:
- postgres
- -c
- listen_addresses=*
environment:
POSTGRES_DB: stator
POSTGRES_HOST_AUTH_METHOD: "trust" # Not recommended, only for demo purposes
volumes:
# seeding
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
# named volume
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
We simply need to update the @Module
of the app.module.ts
and todos.e2e.spec.ts
files:
@Module({
imports: [
TypeOrmModule.forRoot({
type: "mongodb",
host: "127.0.0.1",
port: 27017,
database: "stator",
username: "stator",
password: "secret",
entities: [Todo],
synchronize: true
}),
TodosModule
],
controllers: [AppController],
providers: [AppService],
})
Now when running:
nx test api
You should see this:
Conclusion
Through this article, we have analyzed 3 different web frameworks. We picked the most interesting one according to the ease of use, popularity, performance and code readability. We implemented a complete backend example of this framework, along with other complementary technologies. We have stumbled upon some issues but easily managed to overcome them due to good development patterns. Hopefully, this detailed article has made you discover some new upcoming technologies. If you want to see the complete example implemented, check out stator.
You can read the next article here.
Subscribe to my newsletter
Read articles from Yann Thibodeau directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Yann Thibodeau
Yann Thibodeau
Software developer with solid experience in the startup scene. I built SAAS from the ground up to achieve high level of availability and scalability. Participated in multiple hackathons, always finishing first or second.