Clara Ekekenta Software Engineer and perpetual learner with a passion for OS and expertise in Python, JavaScript, Go, Rust, and Web 3.0.

Full-stack app tutorial with NestJS and React

17 min read 4923

Full-Stack App Tutorial NestJS React

NestJS is a robust framework for building efficient, scalable Node.js server-side applications. Nest offers many features that allow developers to build web apps using their programming paradigms of choice (functional, object-oriented, or functional reactive). Nest also uses robust Node.js frameworks, like Express (its default) and Fastify, and includes inbuilt support for TypeScript, with the freedom to use pure JavaScript.

This tutorial will illustrate the combined power of NestJS and React by using both to build a full-stack video streaming application.

Why video streaming? Well, streaming media is one of the most common use cases for data streaming. In the scenario of a video app, streaming enables a user to watch a video immediately without first downloading the video. Streaming saves the user time and does not consume storage space.

Streaming is advantageous for app performance, as well. With this type of data transmission, data is sent in small segments or chunks, rather than all at once. This is beneficial for app efficiency and cost management.

In this article, we’ll take a deep dive into building the app backend with NestJS, building the app frontend with React, and then deploying the full-stack app.

Getting started

This hands-on tutorial has the following prerequisites:

  • Node.js version >= 10.13.0 installed, except for version 13
  • MongoDB database
  • Ubuntu 20.04, or the OS of your choosing

Building the NestJS backend

To create the app’s backend, we’ll follow these steps:

Installing and configuring NestJS

To install and configure a new NestJS project, we’ll use Nest’s command-line interface.

Open the terminal and run the following command:

npm i -g @nestjs/cli

Once the installation is complete, create a project folder:

mkdir VideoStreamApp && cd VideoStreamApp

Next, create the new NestJS project by running this command:

nest new backend

When prompted to choose a package manager for the project, select npm.

This will create a backend folder, node modules, and a few other boilerplate files. An src folder will also be created and populated with several core files. You can read more about the files in the NestJS official documentation.

Nest, let’s cd into the backend directory:

cd backend

Installing the dependencies

Next, let’s install the dependencies we’ll need for this project:

  • Mongoose: Node.js-based ODM library for MongoDB
  • Multer: Middleware for handling file uploads
  • JSON web token (JWT): Authentication handler
  • Universality unique ID (UUID): Random file name generator

Now, run the following code:

npm i -D @types/multer @nestjs/mongoose mongoose @nestjs/jwt passport-jwt @types/bcrypt bcrypt @types/uuid @nestjs/serve-static

Once the installation of the dependencies is complete, we’ll set up a Nest server for the project.

Setting up the Nest server

Now that we’ve installed the dependencies, let’s set up the Nest server by creating additional folders in the src directory. We’ll create a model, controller service, and utils directories in the src directory.

Next, open the src/main.ts file and enable the CORS Connect/Express npm package by adding the following snippet to the Boostrap function:

 app.enableCors();

Setting up the MongoDB database

We’ll use Mongoose to connect the application to the MongoDB database.

First, we’ll set up a MongoDB database for the application. Open the /src/app.module.ts file, and add the following snippet:

...
import { MongooseModule } from '@nestjs/mongoose';
@Module({
  imports: [
     MongooseModule.forRoot('mongodb://localhost:27017/Stream'),
  ],
...

In this code, we import the MongooseModule into the root AppModuleand use the forRoot method to configure the database.

Defining the schema

Now that the application has been connected to the MongoDB database, let’s define the database schema that will be required by the application. Open the /src/model folder, create a user.schema.ts file, and add the following snippet:

import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
export type UserDocument = User & Document;
@Schema()
export class User {
    @Prop({required:true})
    fullname: string;
    @Prop({required:true, unique:true, lowercase:true})
    email: string;
    @Prop({required:true})
    password: string
    @Prop({default: Date.now() })
    createdDate: Date
}
export const UserSchema = SchemaFactory.createForClass(User)

In this code, we import the @Prop(), @Schema(), @SchemaFactory() decorators from Mongoose. The @Prop() decorator will be used to define the properties of the database collections. The @Schema() decorator will mark a class for the schema definition, and the @SchemaFactory() decorator will generate the schema.



We also define some validity rules in the prop decorator. We expect all fields to be required. We specify that email should be unique and converted to lowercase. We also specify that the current date should be used for the createdDate field’s default date.

Next, let’s create a video.schema.ts file in the model directory and add the following snippet:

import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import * as mongoose from "mongoose";
import { User } from "./user.model";

export type VideoDocument = Video & Document;
@Schema()
export class Video {
    @Prop()
    title: string;
    @Prop()
    video: string;
    @Prop()
    coverImage: string;
    @Prop({ default: Date.now() })
    uploadDate: Date
    @Prop({ type: mongoose.Schema.Types.ObjectId, ref: "User" })
    createdBy: User
}
export const VideoSchema = SchemaFactory.createForClass(Video)

In this code, we import mongoose and the User schema class. This will enable us to reference and save the details about users who create videos with the app.

Defining the application routes

Now that the schema has been defined, it’s time to define the application’s routes. Let’s start by creating a user.controller.ts file in the controllers directory.

Next, we’ll import the decorators needed for the user route, import the User schema class, UserService class (which we’ll create a little later in this article), and the JwtService class to handle user authentication:

import { Body, Controller, Delete, Get, HttpStatus, Param, Post, UploadedFiles, Put, Req, Res } from "@nestjs/common";
import { User } from "../model/user.schema";
import { UserService } from "../model/user.service";
import { JwtService } from '@nestjs/jwt'
...

We’ll use the @Controller() decorator to create the Signup and Signin routes, passing the api URL. We’ll also create a UserController class with a constructor function where we’ll create variables for the userService class and the JwtService class.

@Controller('/api/v1/user')
export class UserController {
    constructor(private readonly userServerice: UserService,
        private jwtService: JwtService
    ) { }
...

Now, we’ll use the @Post decorator to create the Signup and Signin routes, both of which will listen for a Post request:

@Post('/signup')
    async Signup(@Res() response, @Body() user: User) {
        const newUSer = await this.userServerice.signup(user);
        return response.status(HttpStatus.CREATED).json({
            newUSer
        })
    }
    @Post('/signin')
    async SignIn(@Res() response, @Body() user: User) {
        const token = await this.userServerice.signin(user, this.jwtService);
        return response.status(HttpStatus.OK).json(token)
    }
}

In this code, we use the @Res() decorator to send a response to the client, and the @Body() decorator to parse the data in the request body of the Signup route.

We create a new user by sending the user Schema object to the userService signup method and then return the new user to the client with a 201 status code using the inbuilt Nest HttpsStatus.CREATED method.

We send the user schema object and the jwtService as parameters for the Signin routes. Then, we invoke the Signin method in the userService to authenticate the user and return a token to the client if the sign-in is successful.


More great articles from LogRocket:


Creating user authentication

Now we’ll create the app’s security and user identity management. This includes all initial interactions a user will have with the app, such as sign-in, authentication, and password protection.

First, open the /src/app.module.ts file and import jwtServiceand ServeStaticModule into the root AppModule. The ServeStaticModule decorator enables us to render the files to the client.

Next, we’ll create the constants.ts file in the utils directory and export the JWT secret using the following snippet:

export const secret = 's038-pwpppwpeok-dffMjfjriru44030423-edmmfvnvdmjrp4l4k';

On production, the secret key should be securely stored in an .env file or put in a dedicated secret manager. The app module should look similar to the following snippet:

...
import { ServeStaticModule } from '@nestjs/serve-static';
import { JwtModule } from '@nestjs/jwt';
import { secret } from './utils/constants';
import { join } from 'path/posix';

@Module({
  imports: [
    ....
    JwtModule.register({
      secret,
      signOptions: { expiresIn: '2h' },
    }),
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', 'public'),
    }),
   ...
  ],
...

Next, we’ll create a user.service.ts file in the service folder, and add the following snippet:

import { Injectable, HttpException, HttpStatus } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import { User, UserDocument } from "../model/user.schema";
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';
...

In this code, we import Injectable, HttpException, HttpStatus, InJectModel, Model, bcrypt, and JwtService. The @Injectable() decorator attaches metadata, declaring that UserService is a class that can be managed by the Nest inversion of control (IoC) container. The @HttpException() decorator will be used for error handling.

Now, we’ll create the UserService class and inject the schema into the constructor function using the @InjectModel decorator:

...
@Injectable()
export class UserService {
    constructor(@InjectModel(User.name) private userModel: Model<UserDocument>,
    ) { }
...

Next, we’ll create a signup function that will return a user as a promise. We’ll use bcrypt to salt and hash the user’s password for additional security. We’ll save the hashed version of the password to the database and return the newly created user, newUser.

...
async signup(user: User): Promise<User> {
        const salt = await bcrypt.genSalt();
        const hash = await bcrypt.hash(user.password, salt);
        const reqBody = {
            fullname: user.fullname,
            email: user.email,
            password: hash
        }
        const newUser = new this.userModel(reqBody);
        return newUser.save();
    }
...

The next step is to create a signin function that will allow users to log in to the application.

First, we’ll run a query on the userModel to determine if the user record already exists in the collection. When a user is found, we’ll use bcrypt to compare the entered password to the one stored in the database. If the passwords match, we’ll provide the user with an access token. If the passwords do not match, the code will throw an exception.

...
    async signin(user: User, jwt: JwtService): Promise<any> {
        const foundUser = await this.userModel.findOne({ email: user.email }).exec();
        if (foundUser) {
            const { password } = foundUser;
            if (bcrypt.compare(user.password, password)) {
                const payload = { email: user.email };
                return {
                    token: jwt.sign(payload),
                };
            }
            return new HttpException('Incorrect username or password', HttpStatus.UNAUTHORIZED)
        }
        return new HttpException('Incorrect username or password', HttpStatus.UNAUTHORIZED)
    }
...

Next, we create a getOne function to retrieve user data based on an email address:

  async getOne(email): Promise<User> {
        return await this.userModel.findOne({ email }).exec();
    }

Creating the video controller

Now, we’ll create the video controller. First, we need to configure Multer to permit the uploading and streaming of videos.

Open the /src/app.module.ts file and add the following snippet:

...
import { MulterModule } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { v4 as uuidv4 } from 'uuid';

@Module({
 imports: [
    MongooseModule.forRoot('mongodb://localhost:27017/Stream'),
    MulterModule.register({
      storage: diskStorage({
        destination: './public',
        filename: (req, file, cb) => {
          const ext = file.mimetype.split('/')[1];
          cb(null, `${uuidv4()}-${Date.now()}.${ext}`);
        },
      })
    }),
...

In this code, we import the MulterModule into the root AppModule. We import diskStorage from Multer, providing full control to store files to disk. We also import v4 from uuid to generate random names for the files we are uploading. We use the MulterModule.register method to configure the file upload to disk in a /public folder.

Next, we create a video.conmtroller.ts file in the controller directory and add the below snippet:

import { Body, Controller, Delete, Get, HttpStatus, Param, Post, UseInterceptors, UploadedFiles, Put, Req, Res, Query } from "@nestjs/common";
import { Video } from "../model/video.schema"
import { VideoService } from "../video.service";
import { FileFieldsInterceptor, FilesInterceptor } from "@nestjs/platform-express";
...

In this code, we import UseInterceptors, UploadedFiles, Video schema, VideoService class, FileFieldsInterceptor, FilesInterceptor, and other decorators required for the video route.

Next, we’ll create the video controller using the @Controller decorator and pass in the api URL. Then, we’ll create a VideoController class with a constructor() function where we’ll create a private variable for the VideoSevice class.

@Controller('/api/v1/video')
export class VideoController {
    constructor(private readonly videoService: VideoService){}
...

Now, we’ll use the @UseInterceptors decorator to bind the @FileFieldsInterceptor decorator, which extracts files from the request with the @UploadedFiles() decorator.

We’ll pass in the file fields to the @FileFieldsInterceptor decorator. The maxCount property specifies the need for only one file per field.

All of the form data files will be stored in the files variable. We’ll create a requestBody variable and create objects to hold the form data values.

This variable is passed to the VideoService class to save the details of the video, while Multer saves the video and coverImage to the disk. Once the record is saved, the created video object is returned to the client with a 201 status code.

Next, we’ll create Get, Put, Delete routes to get, update, and delete a video using its ID.

...   
    @Post()
    @UseInterceptors(FileFieldsInterceptor([
        { name: 'video', maxCount: 1 },
        { name: 'cover', maxCount: 1 },
    ]))
    async createBook(@Res() response, @Req() request, @Body() video: Video, @UploadedFiles() files: { video?: Express.Multer.File[], cover?: Express.Multer.File[] }) {
        const requestBody = { createdBy: request.user, title: video.title, video: files.video[0].filename, coverImage: files.cover[0].filename }
        const newVideo = await this.videoService.createVideo(requestBody);
        return response.status(HttpStatus.CREATED).json({
            newVideo
        })
    }
    @Get()
    async read(@Query() id): Promise<Object> {
        return await this.videoService.readVideo(id);
    }
    @Get('/:id')
    async stream(@Param('id') id, @Res() response, @Req() request) {
        return this.videoService.streamVideo(id, response, request);
    }
    @Put('/:id')
    async update(@Res() response, @Param('id') id, @Body() video: Video) {
        const updatedVideo = await this.videoService.update(id, video);
        return response.status(HttpStatus.OK).json(updatedVideo)
    }
    @Delete('/:id')
    async delete(@Res() response, @Param('id') id) {
        await this.videoService.delete(id);
        return response.status(HttpStatus.OK).json({
            user: null
        })
    }
}

Creating the video service

With the video controller created, let’s create the video service. We’ll start by creating a video.service.ts file in the service folder. Then, we’ll import the necessary modules using this snippet:

import {
    Injectable,
    NotFoundException,
    ServiceUnavailableException,
} from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { Model } from "mongoose";
import { Video, VideoDocument } from "../model/video.schema";
import { createReadStream, statSync } from 'fs';
import { join } from 'path';
import { Request, Response } from 'express';
...

In this code, we import createReadStream and statSync from the fs module. We use the createReadStream to read files in our file system, and statSync to get the file’s details. Then, we import the Video model and VideoDocument.

Now, we’ll create our VideoService class, and inject the schema into the constructor function using the @InjectModel decorator:

...
@Injectable()
export class VideoService {
    constructor(@InjectModel(Video.name) private videoModel: Model<VideoDocument>) { }
...

Next, we’ll use the createVideo function to save the video details to the database collection and return the created the newVideo.save object:

...
    async createVideo(video: Object): Promise<Video> {
        const newVideo = new this.videoModel(video);
        return newVideo.save();
    }
...

Then, we’ll create the readVideo function to get video details based on the id in the request parameter. We’ll populate the name of the user who created the video and return this name, createdBy, to the client.

...
   async readVideo(id): Promise<any> {
        if (id.id) {
            return this.videoModel.findOne({ _id: id.id }).populate("createdBy").exec();
        }
        return this.videoModel.find().populate("createdBy").exec();
    }
...

Next, we’ll create the streamVideo function to send a video as a stream to the client. We’ll query the database to get the video’s details according to id. If the video id is found, we get the initial range value from the request headers. Then we’ll use the video details to get the video from the file system. We’ll break the video into 1mb chunks and send it to the client. If the video id is not found, the code will throw a NotFoundException error.

...
   async streamVideo(id: string, response: Response, request: Request) {
        try {
            const data = await this.videoModel.findOne({ _id: id })
            if (!data) {
                throw new NotFoundException(null, 'VideoNotFound')
            }
            const { range } = request.headers;
            if (range) {
                const { video } = data;
                const videoPath = statSync(join(process.cwd(), `./public/${video}`))
                const CHUNK_SIZE = 1 * 1e6;
                const start = Number(range.replace(/\D/g, ''));
                const end = Math.min(start + CHUNK_SIZE, videoPath.size - 1);
                const videoLength = end - start + 1;
                response.status(206)
                response.header({
                    'Content-Range': `bytes ${start}-${end}/${videoPath.size}`,
                    'Accept-Ranges': 'bytes',
                    'Content-length': videoLength,
                    'Content-Type': 'video/mp4',
                })
                const vidoeStream = createReadStream(join(process.cwd(), `./public/${video}`), { start, end });
                vidoeStream.pipe(response);
            } else {
                throw new NotFoundException(null, 'range not found')
            }

        } catch (e) {
            console.error(e)
            throw new ServiceUnavailableException()
        }
    }
...

Next, we’ll create update and delete functions to update or delete videos in the database collection:

...
    async update(id, video: Video): Promise<Video> {
        return await this.videoModel.findByIdAndUpdate(id, video, { new: true })
    }
    async delete(id): Promise<any> {
        return await this.videoModel.findByIdAndRemove(id);
    }
}

Although the controllers and services are defined, Nest still doesn’t know they exist and as a result, won’t create an instance of those classes.

To remedy this, we must add the controllers to the app.module.ts file, and add the services to the providers: list. Then, we’ll export the schema and models in the AppModule and register the ServeStaticModule. This enables us to render the files to the client.

....
import { ServeStaticModule } from '@nestjs/serve-static';
import { VideoController } from './controller/video.controller';
import { VideoService } from './service/video.service';
import { UserService } from './service/user.service';
import { UserController } from './controller/user.controller';
import { Video, VideoSchema } from './model/video.schema';
import { User, UserSchema } from './model/user.schema';

@Module({
  imports: [
    ....
    MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
    MongooseModule.forFeature([{ name: Video.name, schema: VideoSchema }]),
    ....
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', 'public'),
    }),
  ],
  controllers: [AppController, VideoController, UserController],
  providers: [AppService, VideoService, UserService],
})

Creating the middleware

At this point, Nest is now aware that the controllers and services in the app exist. The next step is to create middleware to protect the video routes from unauthenticated users.

To get started, let’s create an app.middleware.ts file in the /src folder, and add the following snippet:

import { JwtService } from '@nestjs/jwt';
import { Injectable, NestMiddleware, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { UserService } from './service/user.service';
interface UserRequest extends Request {
    user: any
}
@Injectable()
export class isAuthenticated implements NestMiddleware {
    constructor(private readonly jwt: JwtService, private readonly userService: UserService) { }
    async use(req: UserRequest, res: Response, next: NextFunction) {
        try{

            if (
                req.headers.authorization &&
                req.headers.authorization.startsWith('Bearer')
            ) {
                const token = req.headers.authorization.split(' ')[1];
                const decoded = await this.jwt.verify(token);
                const user = await this.userService.getOne(decoded.email)
                if (user) {
                    req.user = user
                    next()
                } else {
                    throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED)

                }
            } else {
                throw new HttpException('No token found', HttpStatus.NOT_FOUND)

            }
        }catch {
         throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED)
       }
    }
}

In this code, we create an isAuthenticated class, which implements the NestMiddleware. We get the token from the client in the request headers and verify the token. If the token is valid, the user is granted access to the video routes. if the token is invalid, we raise an HttpException.

Next, we’ll open the app.module.ts file and configure the middleware. We’ll exclude the stream route since we’re streaming diretory from a video element in the frontend:

import { Module, RequestMethod, MiddlewareConsumer } from '@nestjs/common';
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(isAuthenticated)
      .exclude(
        { path: 'api/v1/video/:id', method: RequestMethod.GET }
      )
      .forRoutes(VideoController);
  }
}

Now, let’s run the following command to start the NestJS server:

npm run start:dev

Building the React app frontend

To streamline this portion of the tutorial, I’ve created a GitHub repo for the UI of the app’s frontend. To get started, clone to the dev branch and let’s focus on consuming the API and the application logic.

To set up the frontend of the video streaming React app, we’ll build functionality for the following:

Creating the login

With the UI up and running, let’s handle the logic to log users into the app. Open the Component/Auth/Signin.js file, and import axios and useNavigation:

...
import axios from 'axios';
import { useNavigate } from "react-router-dom"
...

In this code, we use axios to make API requests to the backend. useNavigation is used to redirect users after a successful sign-in.

Now, let’s create a handleSubmit handler function with the following snippet:

...
export default function SignIn({setIsLoggedIn}) {
  const [errrorMessage, setErrorMessage] = React.useState('')
  let navigate = useNavigate();

  const handleSubmit = async (event) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    const form = {
      email: formData.get('email'),
      password: formData.get('password')
    };
    const { data } = await axios.post("http://localhost:3002/api/v1/user/signin", form);
    if (data.status === parseInt('401')) {
      setErrorMessage(data.response)
    } else {
      localStorage.setItem('token', data.token);
      setIsLoggedIn(true)
      navigate('/video')
    }
  };
...

In this code, we destructure setIsLoggedIn from our props, create an errorMessage state to display error messages to users during sign-in. Then, we use the formData API to get user Formdata from the text fields and use axios to send a .post request to the backend.

We check the response status to see if the sign-in was successful. With a successful sign-in, we save the token that was sent to the user on the browser’s localStorage, reset the setIsLoggedIn state to true, and redirect the user to the video page. An unsuccessful sign-in will result in a 401(Unauthorized) response. In this case, we’ll display the error message to the user.

Next, we’ll add an onSubmit event to the form component and bind the handleSubmit handler.

...
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
...

If there is an errorMessage, we’ll display it to the user:

<Typography component="p" variant="p" color="red">
  {errrorMessage}
</Typography>

Creating user accounts

Now, we’re ready to log users into the application. Let’s create a Signup component that allows users to create an account. Open the Component/Auth/Signup.js, and import axios and useNavigate:

...
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
...

Next, we’ll create a handleSubmit handler function with the following snippet:

...
export default function SignUp() {
    let navigate = useNavigate();
  const handleSubmit = async (event) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const form = {
      fullname : data.get('fname') +' '+ data.get('lname'),
      email: data.get('email'),
      password: data.get('password')
    };
    await axios.post("http://localhost:3002/api/v1/user/signup", form); 
    navigate('/')
  };
...

In this code, we destructure setIsLoggedIn from the props and create an errorMessage state to display error messages to users during sign-in. Then, we use the formData API to get user input data from the form text fields and send a post request to the backend using axios. After sign-in, we redirect the user to the sign-in page.

Next, we’ll add an onSubmit event to the for component and bind the handleSubmit handler we just created.

Box component="form" noValidate onSubmit={handleSubmit} sx={{ mt: 3 }}>

Adding videos to the library

Now that the user authentication components are created, let’s give users the ability to add videos to the library.

We’ll start by opening the Component/Navbar/Header.js, and importing axios:

...
import axios from 'axios';
...

Next, we’ll destructure the isLoggedIn state from the properties and create three React.useState variables for the video, cover image, and title.

...
const [videos, setVideos] = React.useState("");
const [cover, setCover] = React.useState("");
const [title, setTitle] = React.useState("")
...

Now, we’ll create a submitForm handler function. In our submitForm function, we’ll prevent the form’s default reload, and we’ll get the form submission information using the formData API. To authorize the user to access the video endpoints, we’ll get the user’s token from the browser’s localStorage, and send a .post HTTP request with axios.

...  
   const submitForm = async (e) => {
        e.preventDefault();
        const formData = new FormData();
        formData.append("title", title);
        formData.append("video", video);
        formData.append("cover", cover);
        const token = localStorage.getItem('token');
        await axios.post("http://localhost:3002/api/v1/video", formData, {
            headers: ({
                Authorization: 'Bearer ' + token
            })
        })
    }
...

Next, we’ll bind the submitForm handler to an onSubmit event, and bind the input state set variable to an onChange event. The form component should look like this:

 <Box sx={style}>
    <Typography id="modal-modal-title" variant="h6" component="h2">
    <Box component="form" onSubmit={submitForm} noValidate sx={{ mt: 1 }}>
        <label>Video Title:</label>
        <TextField
           margin="normal"
           required
           fullWidth
           id="title"
           name="title"
           autoFocus
           onChange={(e) => setTitle(e.target.value)}
                                                />
     <label>Select Video:</label>
     <TextField
        margin="normal"
        required
        fullWidth
        id="video"
        name="video"
        autoFocus
        type="file"
        onChange={(e) => setVideos(e.target.files[0])}
    />
    <label>Select Cover Image:</label>
    <TextField
       autoFocus
       margin="normal"
       required
       fullWidth
       name="coverImage"
       type="file"
       id="coverImage"
       onChange={(e) => setCover(e.target.files[0])}
    />
   <Button
      type="submit"
      fullWidth
      variant="contained"
      sx={{ mt: 3, mb: 2 }}
    >
    Upload
   </Button>
</Box>

Displaying the video list

Let’s create a VideoList component to display the videos to users. Open the Component/Video/VideoList.js file, import axios, useParams, useEffect, and useNavigate.

...
import { Link, useNavigate } from 'react-router-dom'
import axios from 'axios';
...

Next, we’ll create a videos state to store the videos and a navigate object to redirect users to the login page when their token expires:

...
    const [videos, setVideos] = React.useState([])
    const navigate = useNavigate();
...

We’ll use the React.useState to send a get request to the API when the component mounts. We’ll get the user’s token from localStorage and useaxios to send it in the request headers to the API:

... 
React.useEffect(() => {
        async function fetchData() {
            try {
                const token = localStorage.getItem('token');
                const {data} = await axios.get('http://localhost:3002/api/v1/video', {
                    headers: ({
                        Authorization: 'Bearer ' + token
                    })
                });
                setVideos(data)
            } catch {
                setLoggedIn(false);
                navigate('/')
            }
        }
        fetchData();
    }, [navigate, setLoggedIn]);
...

Next, we’ll loop through the video list in the videos state and display the list to users. We’ll use the link component to create a link to the video stream page, parsing the video in the URL.

...
{videos.map((video) => {
    return <Grid item xs={12} md={4} key={video._id}>
        <CardActionArea component="a" href="#">
            <Card sx={{ display: 'flex' }}>
                <CardContent sx={{ flex: 1 }}>
                    <Typography component="h2" variant="h5">
                        <Link to={`/video/${video._id}`} style={{ textDecoration: "none", color: "black" }}>{video.title}</Link>
                    </Typography>
                    <Typography variant="subtitle1" color="text.secondary">
                        {video.uploadDate}
                    </Typography>
                </CardContent>
                <CardMedia
                    component="img"
                    sx={{ width: 160, display: { xs: 'none', sm: 'block' } }}
                    image={`http://127.0.0.1:3002/${video.coverImage}`}
                    alt="alt"
                />
            </Card>
        </CardActionArea>
    </Grid>
})}
...

Streaming the videos

Now, let’s create a component to stream any video that a user selects. Open the Componenet/Video/Video.js file and import useNavigation and useParams and axios. We will use useNavigation and useParams to get the id of the video that the user wants to stream.

import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';

We’ll send a GET request with axios with the videoId in the URL parameter and the user’s token in the request headers for authorization.

If the token is invalid, we’ll reset the isLoggedIn state and redirect the user to the login page.

React.useEffect(() => {
        async function fetchData() {
            try {
                const token = localStorage.getItem('token');
                const {data} = await axios.get(`http://127.0.0.1:3002/api/v1/video?id=${videoId}`, {
                    headers: ({
                        Authorization: 'Bearer ' + token
                    })
                });
                setVideoInfo(data)
            } catch {
                setLoggedIn(false);
                navigate('/')
            }
        }
        fetchData();
}, [videoId, navigate, setLoggedIn]);

Now, we’ll display the video details to users, and parse the video URL in the video element to stream the video:

<Container>
    <Grid item xs={12} md={12} marginTop={2}>
        <CardActionArea component="a" href="#">
            <Card sx={{ display: 'flex' }}>
                <CardContent sx={{ flex: 1 }}>
                    <video autoPlay controls width='200'>
                        <source src={`http://localhost:3002/api/v1/video/${videoId}`} type='video/mp4' />
                    </video>
                </CardContent>
            </Card>
        </CardActionArea>
    </Grid>
    <Grid container spacing={2} marginTop={2}>
        <Grid item xs={12} md={6}>
            <Typography variant="subtitle1" color="primary">
                Created by:{videoInfo.createdBy?.fullname}
            </Typography>
        </Grid>
        <Grid item xs={12} md={6}>
            <Typography variant="subtitle1" color="primary">
                Created: {videoInfo.uploadDate}
            </Typography>
        </Grid>
        <Grid item xs={12} md={12}>
            <Typography variant="h5">
                {videoInfo.title}
            </Typography>
        </Grid>
    </Grid>
</Container>

Deploying the app

Now, ensuring we are in the frontend directory, let’s run the below command to deploy the app:

npm start 

Conclusion

In this tutorial, we introduced NestJS as a framework for building scalable Node.js applications. We demonstrated this concept by building a full-stack video streaming application using NestJS and React. The code shared in this tutorial may be extended by adding more styling to the UI and also by adding more components.

The full project code used in this article is available on GitHub. Feel free to deploy this app on Heroku and share it with friends.

Get setup with LogRocket's modern React error tracking in minutes:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. Install LogRocket via NPM or script tag. LogRocket.init() must be called client-side, not server-side.
  3. $ npm i --save logrocket 

    // Code:

    import LogRocket from 'logrocket';
    LogRocket.init('app/id');
    Add to your HTML:

    <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
    <script>window.LogRocket && window.LogRocket.init('app/id');</script>
  4. (Optional) Install plugins for deeper integrations with your stack:
    • Redux middleware
    • ngrx middleware
    • Vuex plugin
Get started now
Clara Ekekenta Software Engineer and perpetual learner with a passion for OS and expertise in Python, JavaScript, Go, Rust, and Web 3.0.

One Reply to “Full-stack app tutorial with NestJS and React”

  1. Thanks for this nice article. I have gone through it completely and wrote he backend code as I read throgh this. Frontend code I took from git. Except few typo errors (in backend), did not find major errors. One package path/posix I had to change as path-posix.

    At the end I realized I can run either frontend or backend. Either you could have given steps how to run both frontend and backend simultaneously or write a separate article in generic way and given a link to follow it. This woud have been helpful for a newbie like me.

Leave a Reply