DEV Community

Cover image for Building a Real-time API with Next.js, Nest.js, and Docker: A Comprehensive Guide
Deepak Sharma
Deepak Sharma

Posted on

Building a Real-time API with Next.js, Nest.js, and Docker: A Comprehensive Guide

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
Enter fullscreen mode Exit fullscreen mode
  • Create a new project using nestjs cli:
nest new project-name
Enter fullscreen mode Exit fullscreen mode
  • 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" ]
Enter fullscreen mode Exit fullscreen mode
  • 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 command yarn 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, executing yarn 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:

Enter fullscreen mode Exit fullscreen mode
  • 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);
}
Enter fullscreen mode Exit fullscreen mode
nest g resource order --no-spec
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • 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]
Enter fullscreen mode Exit fullscreen mode

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>;

Enter fullscreen mode Exit fullscreen mode
  • @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 }]),
  ],
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. We are simply returning all of the orders that are saved in MongoDB with the getAll() function.
  2. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}

Enter fullscreen mode Exit fullscreen mode
  • 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],
Enter fullscreen mode Exit fullscreen mode

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;
  }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" ]
Enter fullscreen mode Exit fullscreen mode
  • 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

Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

Render 'OrderList' just in the home route ('/'):

const Home = () => {
  return (
    <>
      <h1 className="font-bold text-2xl text-center mt-3">Orders</h1>
      <OrdersList />
    </>
  );
};

Enter fullscreen mode Exit fullscreen mode

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,
  };
};
Enter fullscreen mode Exit fullscreen mode
  • 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)