Building a Real-time API with Next.js, Nest.js, and Docker: A Comprehensive Guide
Deepak Sharma
Posted on June 7, 2023
In this blog post, I will guide you through the process of creating a real-time API using the powerful combination of Next.js, Nest.js, and Docker. We will start by building a simple UI and demonstrate how to listen for server changes in real-time from the frontend. Additionally, I will show you how to leverage Docker to containerize your application. As an added bonus, you'll learn how to utilize custom React hooks to enhance the functionality and efficiency of your application. Join me on this step-by-step journey as we explore the world of real-time APIs, containerization, and React hooks.
Check out the source code
Step 1: Installing packages and Containerize the Backend.
Step 2: Writing Backend APIs for the Frontend.
Step 3: Installing packages and Containerize the Frontend.
Step 4: Listening to Real-time Updates on the Frontend.
Let's kickstart this exciting journey by diving into code.
Let's Step 1: Containersize Backend.
- Install the nestjs cli if not installed yet:
npm i -g @nestjs/cli
- Create a new project using nestjs cli:
nest new project-name
- Create a Dockerfile in the root of the folder and paste the following code. Do not worry, I will explain each line of code step by step.
FROM node:16
WORKDIR /app
COPY . .
RUN yarn install
RUN yarn build
EXPOSE 3001
CMD [ "yarn" , "start:dev" ]
FROM node:16
in a Dockerfile specifies the base image to use for the container as Node.js version 16.WORKDIR /app
in a Dockerfile sets the working directory inside the container to /app.COPY . .
in a Dockerfile copies all the files and directories from the current directory into the container.RUN yarn install
in a Dockerfile executes the yarn install command to install project dependencies inside the container.RUN yarn build
in a Dockerfile executes the commandyarn build
during the image building process, typically used for building the project inside the container.EXPOSE 3001
in a Dockerfile specifies that the container will listen on port 3001, allowing external access to that port.CMD [ "yarn" , "start:dev" ]
in a Dockerfile sets the default command to run when the container starts, executingyarn start:dev
.In the root of the folder, create a docker-compose.yml file, and paste the following code. I will explain it briefly because it is really simple.
version: '3.9'
services:
nestapp:
container_name: nestapp
image: your-username/nestjs
volumes:
- type: bind
source: .
target: /app
build: .
ports:
- 3001:3001
environment:
MONGODB_ADMINUSERNAME: root
MONGODB_ADMINPASSWORD: example
MONGODB_URL: mongodb://root:example@mongo:27017/
depends_on:
- mongo
mongo:
image: mongo
volumes:
- type: volume
source: mongodata
target: /data/db
ports:
- 27017:27017
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
volumes:
mongodata:
To build nestjs image, we use Dockerfile and pass some environment variables as well as "bind" volume to apply the changes to the continuous.
Additionally, we are running a mongodb container that stores data on volumes and passes a few environment variables to it.
In order to run the container, run the command docker compose up -d
using the images we created earlier.
Step 2: Writing Backend APIs for the Frontend.
When calling the backend's api from the frontend using port 3001, we will run into the CORS problem. Therefore, update your main to resolve this main.ts
:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
await app.listen(3001);
}
nest g resource order --no-spec
- By running the above command, you will be able to create the necessary files like the module, service, and controller through Nest CLI.
As we will be using MongoDB as our data base run the below package :
npm i @nestjs/mongoose mongoose
- To let Nestjs know that MongoDB is being used in our project, update the import array in 'app.module.ts'.
imports: [MongooseModule.forRoot(process.env.MONGODB_URL), OrderModule]
To create a MongoDB schema, add schema.ts to the'src/order' directory.
Because the schema is so straight forward, let me explain briefly how it works.
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
@Schema({ timestamps: true })
export class Order {
@Prop({ required: true })
customer: string;
@Prop({ required: true })
price: number;
@Prop({ required: true })
address: string;
}
export const OrderSchema = SchemaFactory.createForClass(Order);
// types
export type OrderDocument = HydratedDocument<Order>;
-
@Schema({ timestamps: true })
enables automatic creation of createdAt and updatedAt fields in the schema. - In the schema,
@Prop([ required: true ])
specifies that this field is a required one.
The Order module does not know about the schema, so import it, now your import array should update as follows:
imports: [
MongooseModule.forFeature([{ schema: OrderSchema, name: Order.name }]),
],
Our backend will have two API's
-
http://localhost:3001/order
(GET request) to get all orders from MongoDB. -
http://localhost:3001/order
(POST request) to create a new order.
Let me explain the code in order.service.ts, which is really simple, since you simply need to call those methods from your order.controller.ts.
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Order } from './schema/order.schema';
import { Model } from 'mongoose';
import { OrderGateway } from './order.gateway';
@Injectable()
export class OrderService {
constructor(
@InjectModel(Order.name) private orderModel: Model<Order>,
private orderGateway: OrderGateway,
) {}
async getAll(): Promise<Order[]> {
return this.orderModel.find({});
}
async create(orderData: Order): Promise<Order> {
const newOrder = await this.orderModel.create(orderData);
await newOrder.save();
return newOrder;
}
}
- We are simply returning all of the orders that are saved in MongoDB with the
getAll()
function. - The data needed to generate an order is taken out of the
create()
function, saved in our MongoDB, and then the created order is returned from the function.
Now all you have to do is call those functions from your order.controller.ts file as described below.
@Controller('order')
export class OrderController {
constructor(private readonly orderService: OrderService) {}
@Get()
async findAll(): Promise<Order[]> {
return this.orderService.getAll();
}
@Post()
async create(@Body() orderData: Order): Promise<Order> {
return this.orderService.create(orderData);
}
}
As we will be using Socket.io to listen realtime upadtes lets download requried packages :
yarn add -D @nestjs/websockets @nestjs/platform-socket.io socket.io
We need to develop a "gateway" in order to use socket.io in nestjs. A gateway is nothing more than a straightforward class that simplifies socket handling.Io is incredibly easy.
Create a order.gateway.ts
into your order folder and past the below code.
import {
OnGatewayConnection,
OnGatewayDisconnect,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({ cors: true })
export class OrderGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() server: Server;
handleConnection(client: Socket, ...args: any[]) {
console.log(`Client connected:${client.id}`);
}
handleDisconnect(client: Socket) {
console.log(`Client disconnected:${client.id}`);
}
notify<T>(event: string, data: T): void {
this.server.emit(event, data);
}
}
handleConnection
is called when any new socket is connected.handleDisconnect
is called when any socket is disconneted.The
notify
function emits an event with a specified name and data using a server to other sockets.
Our application does not yet recognise the "gateway" we have created, therefore we must import the order.gateway.ts
file into the "providers" array of the order.module.ts
as shown below:
providers: [OrderService, OrderGateway],
Use the gateway's notify function in the create
method of the 'OrderService' to alert other sockets when an order is made. Don't forget to inject the gateway into the constructor as well.
updated create
function should look like :
async create(orderData: Order): Promise<Order> {
const newOrder = await this.orderModel.create(orderData);
await newOrder.save();
this.orderGateway.notify('order-added', newOrder);
return newOrder;
}
The backend portion is now fully finished.
Step 3: Installing packages and Containerize the Frontend.
First lets start with creating a Next.js application :
npx create-next-app@latest
and go a head with default option.
Now, create a "Dockerfile" in the root of your Next.js application and paste the following; as this is quite similar to the "Dockerfile" for the backend, I won't go into much detail here.
FROM node:16
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD [ "npm" , "run" , "dev" ]
- On
port: 3000
, we'll be running our frontend.
Let's build 'docker-compose.yml' with the code below:
version: "3.9"
services:
nestapp:
container_name: nextapp
image: your-username/nextjs
volumes:
- type: bind
source: .
target: /app
build: .
ports:
- 3000:3000
- We utilise the "bind" volume because when we update the code, the container reflects our changes.
For your frontend container to start, run docker compose up -d
.
Step 4: Listening to Real-time Updates on the Frontend.
Install sockt.io-client to subscribe to server updates.
npm install socket.io-client
On the home page we will display all the orders list.
- inorder to fetch orders we will be creating a custom hook
useOrder
you may think why to use a custom hook ?
I'm using a custom hook because I don't want to make my JSX big, which I believe is difficult to maintain.
Overfiew of useOrder
hook :
- It will essentially maintain the state by retrieving orders and listening to server real-time updates.
import { socket } from "@/utils/socket";
import { useEffect, useState } from "react";
interface Order {
_id: string;
customer: string;
address: string;
price: number;
createdAt: string;
updatedAt: string;
}
const GET_ORDERS_URL = "http://localhost:3001/order";
const useOrder = () => {
const [orders, setOrders] = useState<Order[]>([]);
// responseable to fetch intital data through api.
useEffect(() => {
const fetchOrders = async () => {
const response = await fetch(GET_ORDERS_URL);
const data: Order[] = await response.json();
setOrders(data);
};
fetchOrders();
}, []);
// subscribes to realtime updates when order is added on server.
useEffect(() => {
socket.on("order-added", (newData: Order) => {
setOrders((prevData) => [...prevData, newData]);
});
}, []);
return {
orders,
};
};
export default useOrder;
Create a 'OrdersList' component that will utilise the 'useOrders' hook to produce the list.
OrderList
:
"use client";
import useOrder from "@/hooks/useOrder";
import React from "react";
const OrdersList = () => {
const { orders } = useOrder();
return (
<div className="max-w-lg mx-auto">
{orders.map(({ _id, customer, price, address }) => (
<div key={_id} className="p-2 rounded border-black border my-2">
<p>Customer: {customer}</p>
<p>Price: {price}</p>
<p>Address: {address}</p>
</div>
))}
</div>
);
};
export default OrdersList;
Render 'OrderList' just in the home route ('/'):
const Home = () => {
return (
<>
<h1 className="font-bold text-2xl text-center mt-3">Orders</h1>
<OrdersList />
</>
);
};
I am using TailwindCSS you can skip it.
Time to create another Custom Hook useCreateOrder
which will be responeble to return function which will help th create a order from a form.
useCreateOrder
:
const Initial_data = {
customer: "",
address: "",
price: 0,
};
const useCreateOrder = () => {
const [data, setData] = useState(Initial_data);
const onChange = ({ target }: ChangeEvent<HTMLInputElement>) => {
const { name, value } = target;
setData((prevData) => ({ ...prevData, [name]: value }));
};
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!data.address || !data.customer || !data.price) return;
try {
await fetch("http://localhost:3001/order", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
});
setData(Initial_data);
} catch (error) {
console.error(error);
}
};
return {
onChange,
handleSubmit,
data,
};
};
- As you can see, I control the state and return methods that are used to build orders because doing otherwise results in a thin JSX.
Create a new folder called app/create
, and in the page.tsx
file of the create
folder, there will be a display form that will create an order.
create/page.tsx
const Create = () => {
const { handleSubmit, onChange, data } = useCreateOrder();
return (
<form
onSubmit={handleSubmit}
className="bg-teal-900 rounded absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col p-3 space-y-3 w-1/2"
>
<input
type="text"
name="customer"
onChange={onChange}
value={data.customer}
className="bg-slate-300 p-2 outline-none"
autoComplete="off"
placeholder="customer"
/>
<input
type="text"
name="address"
onChange={onChange}
value={data.address}
autoComplete="off"
className="bg-slate-300 p-2 outline-none"
placeholder="address"
/>
<input
type="number"
name="price"
onChange={onChange}
value={data.price}
autoComplete="off"
className="bg-slate-300 p-2 outline-none"
placeholder="price"
/>
<button type="submit" className="py-2 rounded bg-slate-100">
submit
</button>
</form>
);
};
Once you have completed the form and clicked "Submit," an order will be created.
Congratulations you have created a realtime-api which can listen changes on your server.
Conclusion :
In conclusion, we have successfully built a real-time API using Next.js, Nest.js, and Docker. By containerizing our backend and frontend, and leveraging custom React hooks.
Posted on June 7, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.