Matt Angelosanto
Posted on February 14, 2022
Written by Clara Ekekenta✏️
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 Nest.js, 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 Nest.js backend
To create the app’s backend, we’ll follow these steps:
- Install and configure the Nest.js project
- Install the dependencies
- Set up the Nest server
- Set up the MongoDB database
- Define the schema
- Define the application routes
- Create user authentication
- Create the video controller
- Create the video service
- Create the middleware
Installing and configuring Nest.js
To install and configure a new Nest.js 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 Nest.js 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 AppModule
and 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 userSevervice
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.
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 jwtService
and 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:
//javascript
...
@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:
- Create the login
- Create user accounts
- Add videos to the app library
- Display the video list in the app library
- Stream the videos
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 onSumit
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 onSumit
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 onSumbit
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
.
//javascript
...
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.
Full visibility into production React apps
Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your React apps — start monitoring for free.
Posted on February 14, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.