Skip to content

RESOURCES / BLOG

Insert Ads in between videos using Next.js

We’re all familiar with how Youtube places Ads in videos with a Skip Ad button. In this tutorial, we shall be looking at how we can implement this in our own way using just Cloudinary for storage and Next.js

The completed project is available on Codesandbox.

As per the codesandbox FAQ’s you can only upload files with a maximum limit of 2 MBs. Hence we can only upload two files of 1 MB Each. You can use the following two videos for testing purposes. Sample video 1 Sample video 2

To perfectly test the application, you can find the full code on my Github repository. Clone it to your local machine and you can upload larger file sizes

In this tutorial we shall be using Next.js, a react framework. In the same, we shall also touch on Node.js. It goes without saying that working knowledge of Node.js and Javascript is required. Basic knowledge of React is also recommended. Ensure you have Node.js and NPM installed. You can check out the Node.js website for instructions on how to install. You also need a code editor. I recommend Visual Studio Code, however, any code editor works.

I mentioned that we shall be using Cloudinary. They have amazing APIs which handle media storage, manipulation, and optimization. Before we can use these APIs, we need a few credentials that will authorize our application to access the APIs. You can get started with a free developer account immediately.

Head over to Cloudinary and create a free account if you do not have one already. Once done, head over to the Console page and get your credentials.

Cloudinary Dashboard

This is all you need, for now, just note them down somewhere, and we’ll come back to them when we’re ready to use them.

It’s time to dive into the code. The first thing we need is to scaffold a new Next.js project. Let’s do that now. In your terminal/command line enter and run the following


npx create-next-app ads-in-video-with-cloudinary &&  cd ads-in-video-with-cloudinary

Code language: JavaScript (javascript)

This will create a new project named ads-in-video-with-cloudinary and change the directory to the same. This is a basic Next project that allows us to get up and running easily. There’s a ton of other features you can add. Have a look at the Next.js website to learn more.

In this section, we’ll define the methods that will handle fetching videos from cloudinary, uploading, and deleting. First, we need to install the cloudinary Node.js SDK.


npm install -S cloudinary

Next, create a folder called lib at the root of your project. This folder will hold our shared functions. Inside the lib folder, create a new file named cloudinary.js. Paste the following code inside


// lib/cloudinary.js

  

// Import the v2 api and rename it to cloudinary

import  {

v2  as  cloudinary,

UploadApiResponse,

TransformationOptions,

}  from  "cloudinary";

  

// Initialize the SDK with cloud_name, api_key, and api_secret

cloudinary.config({

cloud_name:  process.env.CLOUD_NAME,

api_key:  process.env.API_KEY,

api_secret:  process.env.API_SECRET,

});

  

const  FOLDER_NAME  =  "videos-with-ads/";

  

/**

*

* @param  {string}  path

* @param  {TransformationOptions}  transformation

* @returns  {Promise<UploadApiResponse>}

*/

export  const  handleCloudinaryUpload  =  (path,  transformation  =  [])  =>  {

// Create and return a new Promise

return  new  Promise((resolve,  reject)  =>  {

// Use the SDK to upload media

cloudinary.uploader.upload(

path,

{

// Folder to store video in

folder:  FOLDER_NAME,

// Type of resource

resource_type:  "auto",

allowed_formats:  ["mp4"],

transformation,

},

(error,  result)  =>  {

if  (error)  {

// Reject the promise with an error if any

return  reject(error);

}

  

// Resolve the promise with a successful result

return  resolve(result);

}

);

});

};

  

/**

*

* @param  {string[]}  ids

* @returns

*/

export  const  handleCloudinaryDelete  =  async  (ids)  =>  {

return  new  Promise((resolve,  reject)  =>  {

cloudinary.api.delete_resources(

ids,

{

resource_type:  "video",

},

(error,  result)  =>  {

if  (error)  {

return  reject(error);

}

  

return  resolve(result);

}

);

});

};

Code language: JavaScript (javascript)

At the very top, we import the v2 cloudinary API and rename it to cloudinary. Just after that, we initialize it by calling the config method and passing to it cloud_name, api_key, and api_secret. To keep our keys secure, we’ve used environment variables. We’ll define these shortly.

Next, we have our folder name. We want to store all our videos in one folder. This can be whatever name you want, you don’t even have to store them inside any particular folder.

The handleCloudinaryUpload function takes in a video path and an array of transformations. The function will handle the upload to cloudinary by calling the uploader.upload method on the SDK. The uploader.upload method requires a file path as the first argument and an options object as the second. Have a look at all the options available in the docs. The transformation option is of particular interest in this case. Here we can define what transformations we want to be run on our video. We’ll pass these transformations through the transformation parameter.

The handleCloudinaryDelete function takes in an array of public IDs. It will call the api.delete_resources method on the cloudinary SDK and pass the IDs. The resources(videos) with the IDs passed will be deleted.

The final thing for this file is to define the environment variables that we used. Next.js comes with support for environment variables built-in as documented in the docs. Create a file at the root of your project called .env.local. Paste the following inside.


CLOUD_NAME=YOUR_CLOUD_NAME

API_KEY=YOUR_API_KEY

API_SECRET=YOUR_API_SECRET

Make sure to replace YOUR_CLOUD_NAME, YOUR_API_KEY, and YOUR_API_SECRET with the appropriate keys from the cloudinary credentials that we got from the Cloudinary API Keys section. Remember the ones I said we’ll use later.

We’re done with this file.

We need a way to keep track of the resources(videos) that we upload to cloudinary. The database is really up to your specific needs. It could be production-ready relational/non-relational DBs, memory caches, e.t.c. For the sake of brevity and simplicity of this tutorial, we’ll use a simple flat file database. Please keep in mind that these kind of DBs are not suitable for production. We’re only opting for one because this is a simple tutorial. You might want to look at something like Mongo DB, Postgres, or something similar. We’ll be using Nedb, an embedded persistent or in-memory database for Node.js. Let’s first install the dependencies.


npm install -S nedb

Create a new file called database.js under the lib/ folder. Paste the following code inside.


// lib/database.js

  

import  {  join  }  from  "path";

import  Datastore  from  "nedb";

  

// Custom database class using nedb, a simple flat-file database

class  Database  {

constructor()  {

this.db  =  {

// Create a new datastore for videos

videos:  new  Datastore({

filename:  join("data",  "videos.db"),

autoload:  true,

}),

};

}

  

// This method queries the database for videos

getVideos()  {

return  new  Promise((resolve,  reject)  =>  {

this.db.videos.find().exec((err,  videos)  =>  {

if  (err)  {

reject(err);

return;

}

  

resolve(videos);

});

});

}

  

// This method adds a new video to the videos datastore

addNewVideo(video)  {

return  new  Promise((resolve,  reject)  =>  {

this.db.videos.insert(video,  (err,  newDoc)  =>  {

if  (err)  {

reject(err);

return;

}

  

resolve(newDoc);

});

});

}

  

// This method gets a video from the database using the video public id

getVideo(videoId)  {

return  new  Promise((resolve,  reject)  =>  {

this.db.videos.findOne({ _id:  videoId  },  (err,  video)  =>  {

if  (err)  {

reject(err);

return;

}

  

resolve(video);

});

});

}

  

// This method deletes a video from the database using the video id

deleteVideo(videoId)  {

return  new  Promise((resolve,  reject)  =>  {

this.db.videos.remove({ _id:  videoId  },  {},  (err,  numRemoved)  =>  {

if  (err)  {

reject(err);

return;

}

  

resolve(numRemoved);

});

});

}

}

  

// Create a new instance of the database class and export it as a singleton

export  const  database  =  new  Database();

Code language: JavaScript (javascript)

This is a class that implements a few simple methods to handle our database operations. We have a class called Database. In the constructor, we initialize our DB and define a collection/datastore called videos. This will create a file called videos.db under a data folder. Create a folder called data at the root of your project. I’ll not go into the specifics of creating and initializing the database. Have a look at the documentation for that. Nedb uses a syntax that is very similar to that of MongoDB to find, insert and delete documents. We define a few methods getVideos, addNewVideo, getVideo and deleteVideo. The names explain the function fairly well. To better understand the syntax used in each of the methods, have a look at the documentation on inserting documents,finding documents and removing documents. We then use the singleton pattern to create an instance of the Database class.

Next.js has built-in support for API routes, see here. It’s important to grasp a few concepts here, so take some time to go over the docs if you’re not familiar.

We want to create an endpoint that matches api/videos and api/videos/:id. Create a folder called videos under the pages/api folder. Inside the pages/api/videos folder, create two files, one named index.js and another called [id].js. If you’re not familiar, the former will allow us to handle requests to api/videos and the latter to api/videos/:id. Read more about the latter here. This is why I mentioned that you need to go over the documentation to understand a few things.

Open pages/api/videos/index.js and paste the following code inside


// pages/api/videos/index.js

  

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction

import  {  IncomingForm,  Fields,  Files  }  from  "formidable";

import  {  handleCloudinaryUpload  }  from  "../../../lib/cloudinary";

import  {  database  }  from  "../../../lib/database";

  

// Custom config for our API route

export  const  config  =  {

api:  {

bodyParser:  false,

},

};

  

export  default  async  function  handler(req,  res)  {

switch  (req.method)  {

case  "GET":  {

try  {

const  result  =  await  handleGetRequest();

  

return  res.status(200).json({ message:  "Success",  result  });

}  catch  (error)  {

return  res.status(400).json({ message:  "Error",  error  });

}

}

  

case  "POST":  {

try  {

const  result  =  await  handlePostRequest(req);

  

return  res.status(200).json({ message:  "Success",  result  });

}  catch  (error)  {

return  res.status(400).json({ message:  "Error",  error  });

}

}

  

default:  {

return  res.status(405).json({ message:  "Method not allowed"  });

}

}

}

  

const  handleGetRequest  =  ()  =>  database.getVideos();

  

const  handlePostRequest  =  async  (req)  =>  {

// Get the form data using the parseForm function

const  data  =  await  parseForm(req);

  

// Get the main video file

const  video  =  data.files.video;

  

// Get the ad video file

const  adVideo  =  data.files.adVideo;

  

// Upload the main video file to Cloudinary

const  videoUploadResult  =  await  handleCloudinaryUpload(video.path);

  

// Get the main video's midpoint

const  videoMidPoint  =  Math.round(videoUploadResult.duration  /  2);

  

// Upload the ad video file to Cloudinary

const  adVideoUploadResult  =  await  handleCloudinaryUpload(adVideo.path,  [

{

background:  "black",

aspect_ratio:  `${videoUploadResult.width  /  videoUploadResult.height}`,

crop:  "lpad",

},

{ effect:  "progressbar:frame:FF0000:12"  },

]);

  

// Add the main video and ad video to the database

const  result  =  await  database.addNewVideo({

video:  videoUploadResult,

adVideo:  adVideoUploadResult,

adPlacement:  videoMidPoint,

});

  

return  result;

};

  

/**

*

* @param  {*}  req

* @returns  {Promise<{ fields:Fields; files:Files; }>}

*/

const  parseForm  =  (req)  =>  {

return  new  Promise((resolve,  reject)  =>  {

const  form  =  new  IncomingForm({ keepExtensions:  true, multiples:  true  });

  

form.parse(req,  (error,  fields,  files)  =>  {

if  (error)  {

return  reject(error);

}

  

return  resolve({  fields,  files  });

});

});

};

Code language: PHP (php)

At the top, we import a few things from a package called formidable. We’ll install this shortly. We also import the functions we created earlier from lib/cloudinary.js and the database instance from lib/database.js. After the imports, we export a custom config object for our API route. This basically tells Next.js that we’ll be using our own body-parser and not to use the default. Read more about custom configs for API routes here. Next comes our handler function. We use a switch statement to handle only the GET and POST requests. Other requests will just fail with a status code of 405 – Method not allowed. The handleGetRequest method will just fetch all the videos saved in our custom database. The handlePostRequest method takes in the incoming HTTP request. It then delegates parsing of the form to a function called parseForm. After the form data is parsed, we get the main video file and the ad video file. We first upload the main video file to cloudinary without any transformations applied to it. We then get the midpoint of the duration of the uploaded video. This is where we will place our ad. Remember that you can place the ad at whichever point you want but for the simplicity of this, we’re just going to place it in the middle. We also round it to the nearest integer. Next, we upload the ad video file to cloudinary. This time we need to apply a few transformations. First, we need the ad video to be the same aspect ratio as the main video so that we don’t see a lot of jank in the front end as we switch between the two videos. For that, we pass the following transformation.


{

background:  "black",

aspect_ratio:  `${videoUploadResult.width  /  videoUploadResult.height}`,

crop:  "lpad",

}

Code language: CSS (css)

We’re telling Cloudinary to make the video fit into the aspect ratio of the main video but instead of stretching it to fill, we want to apply some padding to the extra space. The next transformation we want is to add a progress bar that shows the progress of the ad. For this, we pass the following


{  effect:  "progressbar:frame:FF0000:12"  }

Code language: CSS (css)

Read about these transformations from the video transformations documentation. Next, we save the main video, ad video, and ad placement time to our custom DB.

The parseForm function used formidable to parse our form data.

Let’s add the missing dependency. We use a library called Formidable that will parse the incoming form data for us and get the uploaded files. You can think of this like Multer. Let’s install it.


npm install -S formidable

Let’s move on to the other file. Paste the following inside pages/api/videos/[id].js.


// pages/api/videos/[id].js

  

import  {  handleCloudinaryDelete  }  from  "../../../lib/cloudinary";

import  {  database  }  from  "../../../lib/database";

  

export  default  async  function  handler(req,  res)  {

const  {  id  }  =  req.query;

  

switch  (req.method)  {

case  "GET":  {

try  {

if  (!id)  {

throw  "id param is required";

}

  

const  result  =  await  handleGetRequest(id);

  

return  res.status(200).json({ message:  "Success",  result  });

}  catch  (error)  {

return  res.status(400).json({ message:  "Error",  error  });

}

}

case  "DELETE":  {

try  {

if  (!id)  {

throw  "id param is required";

}

  

await  handleDeleteRequest(id);

  

return  res.status(200).json({ message:  "Success"  });

}  catch  (error)  {

return  res.status(400).json({ message:  "Error",  error  });

}

}

default:  {

return  res.status(405).json({ message:  "Method not allowed"  });

}

}

}

  

const  handleGetRequest  =  (id)  =>  database.getVideo(id);

  

const  handleDeleteRequest  =  async  (id)  =>  {

const  video  =  await  database.getVideo(id);

  

await  handleCloudinaryDelete([

video.video.public_id,

video.adVideo.public_id,

]);

  

await  database.deleteVideo(id);

};

Code language: JavaScript (javascript)

Nothing fancy here. We have the same handler function as before that will only handle the GET and DELETE HTTP requests. The only unique thing is that we’re passing a param called id in the url and we get that from the incoming request object.

The handleGetRequest gets a video from the database that has the id passed in the params. handleDeleteRequest deletes the same from cloudinary then from the database. This is for our backend.

We’ll have a simple frontend where we can upload videos, view all videos and play each video. Let’s first create a layout/partial component. This component will wrap all our pages so that we have a unified layout across the board. It also minimizes code repetition. Create a folder at the root called components. Create a file called Layout.js inside the components/ folder. Paste the following code inside


import  Head  from  "next/head";

import  Link  from  "next/link";

  

export  default  function  Layout({  children  })  {

return  (

<div>

<Head>

<title>Ads in video with cloudinary</title>

<meta name="description"  content="Ads in video with cloudinary"  />

<link rel="icon"  href="/favicon.ico"  />

</Head>

  

<nav>

<div className="title">

<h1>Ads in video with cloudinary</h1>

</div>

<ul className="links">

<li>

<Link  href="/">Home</Link>

</li>

<li>

<Link  href="/videos">Videos</Link>

</li>

</ul>

</nav>

<main>{children}</main>

<style jsx>{`

nav {

height: 100px;

background-color: #f5f5f5;

display: flex;

flex-flow: row nowrap;

align-items: center;

justify-content: space-between;

}

  

nav div.title {

margin-left: 50px;

}

  

nav ul {

margin-right: 50px;

display: flex;

list-style: none;

}

  

nav ul li {

font-weight: bold;

margin: 0 10px;

}

  

nav ul li:hover {

color: #6f00ff;

}

  

main {

height: calc(100vh - 100px);

}

`}</style>

</div>

);

}

Code language: JavaScript (javascript)

This is just basic React.js.

Pro Tip: You can change the extension of your frontend files from .js to .jsx for better code completion and IntelliSense.

Next, create a file called index.js inside the pages folder. Paste the following inside.


// pages/index.js

  

import  {  useRouter  }  from  "next/router";

import  Link  from  "next/link";

import  {  useState  }  from  "react";

import  Layout  from  "../components/Layout";

  

export  default  function  Home()  {

// Get the router from Next.js

const  router  =  useRouter();

  

// State for the main video input

const  [videoFile,  setVideoFile]  =  useState(null);

  

// State for the ad video input

const  [adFile,  setAdFile]  =  useState(null);

  

// Loading state

const  [loading,  setLoading]  =  useState(false);

  

const  handleFormSubmit  =  async  (e)  =>  {

e.preventDefault();

  

setLoading(true);

  

try  {

// Get the form data

const  formData  =  new  FormData(e.target);

  

// Post the form data to the /api/videos endpoint

const  response  =  await  fetch("/api/videos",  {

method:  "POST",

body:  formData,

});

  

const  data  =  await  response.json();

  

if  (!response.ok)  {

throw  data;

}

  

// Navigate to the videos page

router.push("/videos");

}  catch  (error)  {

// TODO: Show error message to the user

console.error(error);

}  finally  {

setLoading(false);

}

};

  

return  (

<Layout>

<div className="wrapper">

<h1>Upload a video + Ad</h1>

<hr />

<form onSubmit={handleFormSubmit}>

<label htmlFor="video">

<b>Select a video for upload</b>

</label>

<input

type="file"

name="video"

id="video"

multiple={false}

accept=".mp4"

required

disabled={loading}

onChange={(e)  =>  {

setVideoFile(e.target.files[0]);

}}

/>

<hr />

<label htmlFor="video">

<b>

Select a short video ad that will be placed in the middle of your

video

</b>

</label>

<input

type="file"

name="adVideo"

id="adVideo"

multiple={false}

accept=".mp4"

required

disabled={loading}

onChange={(e)  =>  {

setAdFile(e.target.files[0]);

}}

/>

<button type="submit"  disabled={loading  ||  !videoFile  ||  !adFile}>

Upload

</button>

</form>

<hr />

<Link  href="/videos"  passHref>

<button>View Uploaded Videos</button>

</Link>

</div>

<style jsx>{`

div.wrapper {

background-color: #ffffff;

height: 100%;

display: flex;

flex-flow: column;

justify-content: center;

align-items: center;

}

  

div.wrapper > hr {

min-width: 600px;

}

  

div.wrapper form {

min-width: 600px;

min-height: 300px;

padding: 20px;

background-color: #f5f5f5;

display: flex;

flex-flow: column;

justify-content: center;

align-items: flex-start;

border-radius: 5px;

}

  

div.wrapper form hr {

width: 100%;

}

  

div.wrapper form button {

padding: 20px;

min-width: 200px;

border: none;

background-color: #7700ff;

color: white;

font-weight: bold;

margin-top: 20px;

border-radius: 5px;

}

  

div.wrapper form button:disabled {

background-color: #cccccc;

}

  

div.wrapper form button:hover:not([disabled]) {

background-color: #ff0095;

}

  

div.wrapper > button {

padding: 20px;

min-width: 200px;

border: none;

background-color: #7700ff;

color: white;

font-weight: bold;

margin-top: 20px;

border-radius: 5px;

}

  

div.wrapper > button:hover {

background-color: #ff0095;

}

`}</style>

</Layout>

);

}

Code language: JavaScript (javascript)

This will be our upload page. As I mentioned earlier, basic knowledge of React.js is recommended. We’re using different React hooks here for our state. Read about React hooks from the official docs and the useState hook here. The other hook we’re using is the useRouter hook from Next.js. Read about this here. The handleFormSubmit function just handles the form submission. It gets the form data(main video and ad video) and posts that to the api/videos endpoint that we created earlier. It then navigates the user to the videos page that we will create shortly. For the HTML, we just have our Layout component wrapping a form that has two inputs, one for the main video and another for the ad video.

Next, create a folder called videos under pages/. Remember that this is different from the one inside pages/api/. Again, just like dynamic API routes, we also have dynamic URLs. Read more about those here. Create a new file called index.js inside the pages/videos folder. Please note that this is not the same file that we have in pages/ or pages/api/videos. Paste the following inside pages/videos/index.js


// pages/videos/index.js

  

import  {  useCallback,  useEffect,  useState  }  from  "react";

import  Image  from  "next/image";

import  Link  from  "next/link";

import  Layout  from  "../../components/Layout";

  

export  default  function  Videos()  {

// State for the videos

const  [videos,  setVideos]  =  useState([]);

  

// Loadin state

const  [loading,  setLoading]  =  useState(false);

  

// Memoized function to Fetch the videos

const  getVideos  =  useCallback(async  ()  =>  {

try  {

// Make a GET request to the /api/videos endpoint

const  response  =  await  fetch("/api/videos",  {

method:  "GET",

});

  

const  data  =  await  response.json();

  

if  (!response.ok)  {

throw  data;

}

  

setVideos(data.result);

}  catch  (error)  {

// TODO: Show error message to the user

console.error(error);

}

},  []);

  

// Fetch the videos on component mount

useEffect(()  =>  {

getVideos();

},  [getVideos]);

  

const  handleDeleteVideo  =  async  (id)  =>  {

try  {

setLoading(true);

  

// Make a DELETE request to the /api/videos/:id endpoint

const  response  =  await  fetch(`/api/videos/${id}`,  {

method:  "DELETE",

});

  

const  data  =  await  response.json();

  

if  (!response.ok)  {

throw  data;

}

  

// Refresh the videos

getVideos();

}  catch  (error)  {

// TODO: Show error message to the user

console.error(error);

}  finally  {

setLoading(false);

}

};

  

return  (

<Layout>

{videos.length  ?  (

<div className="wrapper">

{videos.map((video,  index)  =>  (

<div className="video"  key={`video-${index}`}>

<Link  href={`/videos/${video._id}`}  passHref>

<div className="thumbnail">

<Image

className="thumbnail-image"

layout="fill"

src={video.video.secure_url.replace(".mp4",  ".gif")}

alt={video.video.secure_url}

></Image>

<div className="controls">Click to play</div>

</div>

</Link>

<div className="video-info">

<p>{video.video.original_filename}</p>

<a>

<Link  href={video.video.secure_url}>

{video.video.secure_url}

</Link>

</a>

<button

disabled={loading}

onClick={()  =>  {

handleDeleteVideo(video._id);

}}

>

Delete

</button>

</div>

</div>

))}

</div>

)  :  (

<div className="no-videos">

<p>No videos yet</p>

<Link  href="/"  passHref>

<button>Upload videos</button>

</Link>

</div>

)}

<style jsx>{`

div.wrapper {

padding: 10px;

background-color: #ffffff;

width: 100%;

height: 100%;

overflow-y: auto;

display: flex;

flex-flow: row wrap;

justify-content: flex-start;

align-items: flex-start;

gap: 10px;

}

  

div.wrapper div.video {

background-color: #ffffff;

flex: 0 0 400px;

height: 320px;

box-shadow: 0 0 1px rgba(34, 25, 25, 0.4);

overflow: hidden;

}

  

div.wrapper div.video:hover {

box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);

}

  

@media only screen and (max-width: 600px) {

div.wrapper div.video {

flex: 1 0 500px;

height: 400px;

}

}

  

div.wrapper div.video div.thumbnail {

position: relative;

width: 100%;

height: 70%;

cursor: pointer;

}

  

div.wrapper div.video div.thumbnail .thumbnail-image {

width: 100%;

display: none;

}

  

div.wrapper div.video div.controls {

position: absolute;

top: 0;

right: 0;

height: 100%;

width: 100%;

background-color: rgba(0, 0, 0, 0.4);

display: flex;

justify-content: center;

align-items: center;

color: #ffffff;

font-weight: bold;

font-size: 1em;

}

  

div.wrapper div.video div.video-info {

width: 100%;

height: 30%;

padding: 10px;

overflow: hidden;

}

  

div.wrapper div.video div.video-info p {

margin: 5px 0;

}

  

div.wrapper div.video div.video-info a {

white-space: nowrap;

overflow-y: auto;

text-overflow: ellipsis;

}

  

div.no-videos {

background-color: #ffffff;

width: 100%;

height: 100%;

display: flex;

flex-flow: column;

justify-content: center;

align-items: center;

}

`}</style>

</Layout>

);

}

Code language: JavaScript (javascript)

On this page, we show all the videos that we have uploaded. We have the videos state that will hold our videos and the loading state. We then have a memoized callback function that will make a GET request to the /api/videos endpoint and get all our videos. We’re using the useCallback hook to create a memoized function. Read about this here. Right after that is the useEffect hook. This is used to run side effects in our component. In this case, we want to get all videos when the component mounts. Read about this hook here. We then have a handleDeleteVideo function that takes in a video ID then makes a DELETE request to the /api/videos/:id endpoint. For the HTML we just have our Layout component wrapping all the videos’ thumbnails. Here we take the video’s url and change the extension from .mp4 to .gif. We then use this to show a thumbnail of the video. Notice how we’re using the Image component from Next.js. This allows us to optimize the images we use in our application. One of these optimizations requires us to define the different domains that we’ll be getting external images from. Read more about it in the official docs. Create a file called next.config.js in the root of your project if you don’t already have one and paste the following code inside.


module.exports  =  {

// ... other code that was there

images:  {

domains:  ["res.cloudinary.com"],

},

};

Code language: JavaScript (javascript)

Clicking on one of the video’s thumbnail will navigate you to the /videos/:id URL. Let’s create that page now. Create a file called [id].js under pages/videos. Paste the following inside pages/videos/[id].js.


// pages/videos/[id].js

import  {  useRouter  }  from  "next/router";

import  {

useCallback,

useEffect,

useRef,

useState,

MutableRefObject,

SyntheticEvent,

}  from  "react";

import  Layout  from  "../../components/Layout";

  

export  default  function  Video()  {

// Get the router

const  router  =  useRouter();

  

// Get the video id from the url

const  {  id  }  =  router.query;

  

// State to store our video

const  [video,  setVideo]  =  useState(null);

  

/**

* Stores a reference to our video element

* @type  {MutableRefObject<HTMLVideoElement>}

*/

const  videoRef  =  useRef(null);

  

/**

* Stores a reference to our ad video element

* @type  {MutableRefObject<HTMLVideoElement>}

*/

const  adVideoRef  =  useRef(null);

  

/**

* Stores a reference to our skip button

* @type  {MutableRefObject<HTMLButtonElement>}

*/

const  skipButtonRef  =  useRef(null);

  

const  getVideo  =  useCallback(async  ()  =>  {

try  {

// Post the form data to the /api/videos/:id endpoint

const  response  =  await  fetch(`/api/videos/${id}`,  {

method:  "GET",

});

  

const  data  =  await  response.json();

  

if  (!response.ok)  {

throw  data;

}

  

setVideo(data.result);

}  catch  (error)  {

// TODO: Show error message to user

console.error(error);

}

},  [id]);

  

// Get videos on component mount

useEffect(()  =>  {

getVideo();

},  [getVideo]);

  

/**

* This function throttles our event listener to prevent it from firing too often

* @param  {Function}  func Callback function to execute

* @param  {number}  delay Delay in milliseconds

* @returns  {Function}

*/

const  throttle  =  (func,  delay  =  800)  =>  {

// Previously called time of the function

let  prev  =  0;

return  (...args)  =>  {

// Current called the time of the function

let  now  =  new  Date().getTime();

  

// If the difference is greater than delay call

// the function again.

if  (now  -  prev  >  delay)  {

prev  =  now;

  

// "..." is the spread operator here

// returning the function with the

// array of arguments

return  func(...args);

}

};

};

  

/**

* This function is called when the main video playback progress changes

* @param  {SyntheticEvent<HTMLVideoElement, Event>}  ev

*/

const  onVideoTimeUpdate  =  (ev)  =>  {

const  {  currentTime  }  =  ev.target;

  

// Check if the video's current time matches the ad's beginning time

if  (

Math.round(currentTime).toFixed(2)  ===

Math.round(video.adPlacement).toFixed(2)

)  {

console.log("Play ad");

// Pause the main video

videoRef.current.pause();

  

// Show the ad video element on top of the main video

adVideoRef.current.style.display  =  "block";

  

// Show the skip ad button

skipButtonRef.current.style.display  =  "block";

  

// Play the ad video

adVideoRef.current.play();

}

};

  

/**

* This function is called when the skip ad button is clicked

*/

const  skipAd  =  ()  =>  {

// Make sure the ad video is paused

adVideoRef.current.pause();

  

// Hide the ad video element

adVideoRef.current.style.display  =  "none";

  

// Hide the skip ad button

skipButtonRef.current.style.display  =  "none";

  

// Increase the main video's current time by one second to prevent the ad from playing twice

videoRef.current.currentTime  +=  1;

  

// Play the main video

videoRef.current.play();

};

  

return  (

<Layout>

<div className="wrapper">

{video  ?  (

<div className="video-wrapper">

<video

ref={videoRef}

id="video"

src={video.video.secure_url}

preload="auto"

controls

onTimeUpdate={throttle(onVideoTimeUpdate)}

></video>

<video

ref={adVideoRef}

id="adVideo"

src={video.adVideo.secure_url}

preload="auto"

controls

onEnded={()  =>  {

console.log("Ad ended");

skipAd();

}}

></video>

<button className="skip"  ref={skipButtonRef}  onClick={skipAd}>

SKIP AD

</button>

</div>

)  :  (

<div className="loading">Loading...</div>

)}

</div>

<style jsx>{`

.wrapper {

background-color: #ffffff;

height: 100%;

width: 100%;

}

  

.video-wrapper {

position: relative;

width: 80%;

margin: 20px auto;

background-color: #ffffff;

}

  

.video-wrapper video {

width: 100%;

height: 100%;

object-fit: cover;

}

  

.video-wrapper video#adVideo {

position: absolute;

top: 0;

left: 0;

width: 100%;

height: 100%;

object-fit: cover;

display: none;

}

  

.video-wrapper button.skip {

position: absolute;

bottom: 100px;

right: 20px;

padding: 15px;

min-width: 100px;

font-weight: bold;

border: none;

background-color: #ffffff7c;

display: none;

}

  

.video-wrapper button.skip:hover {

background-color: #ffffff;

}

  

.loading {

display: flex;

justify-content: center;

align-items: center;

width: 80%;

height: 80%;

margin: 20px auto;

background-color: #f3f3f3;

}

`}</style>

</Layout>

);

}

  

Code language: JavaScript (javascript)

Here we have the same hooks from before save for the useRef hook. This hook allows us to store a reference to any HTML element. Read more about it here. We extract the id param from the router object. Then we have references to our main video element, our ad video element, and our skip button element. We then have a memoized callback function that makes a GET request to the api/videos/:id endpoint to get the video with the id we get from the URL params. We have an onVideoTimeUpdate function that will be called when the main video playback progress changes. Inside this function, we want to get the current main video time and check if it matches the ad placement time. If it does, we want to pause the main video, then show the ad video and play it. Now we don’t want the onVideoTimeUpdate function to be called too often, so we create a throttle function that will delay the calls. The calls will be instead made after every 800 milliseconds or however long you want. I just found 800ms to be optimal for our use case, and also it ensures that the function is called at least once for every second. We also have a skipAd function that makes sure the ad video is paused/ended, hides the ad video element, hides the skip button, increments the main video’s current playback time by a second then unpauses the main video. For the HTML, we just have our main video and then our ad video placed on top of the main video(on the Z-axis). We accomplish this using CSS’ absolute position. The ad video element is not shown until the main video gets to the ad placement time.

Our simple application is complete. The folder structure inside pages/ may be a bit confusing. Have a look at the following to make it clear.

  • pages/

  • api/

  • videos/

  • [id].js

  • index.js

  • videos/

  • [id].js

  • index.js

  • _app,js

  • index.js

And that’s it. You’re ready to run your application. Open your terminal and run the following


npm run dev

This will run in development mode. I won’t go over-optimizing for a production build. You can check that in the Next.js docs. Please remember that this is a simple implementation. For production-level, you want to look at things such as lazy loading videos, API authentication/authorization, performant databases, Cloudinary’s long-running operations, e.t.c.

Cheers 🥂 to you for making it to the end. You can find the full code on my Github

🥳🥳🥳

Start Using Cloudinary

Sign up for our free plan and start creating stunning visual experiences in minutes.

Sign Up for Free