Instagram’s boomerang-style videos have become increasingly popular over the past few years. Let’s see how we can create the same programmatically using Cloudinary and Next.js
The completed project is available on Codesandbox.
Ensure you have Node.js + NPM installed. The official documentation has a guide on how you can install it on your development environment. It is also ideal that you have a working knowledge of Javascript and React.js. Apart from that, the only other thing we need is Cloudinary API credentials.
Cloudinary is a media management solution that offers a wide array of APIs that allow for upload, optimization, and manipulation of media. It’s free and easy to get started with a developer account.
Sign in to your Cloudinary account and navigate to the Console. You’ll find your credentials on the console page at the top.
Remember the location of your credentials, we will need to use them later on.
We will be using Next.js, a react framework to build our application. The concept can, however, be applied to any Node.js application with a compatible front-end. Let’s start a new Next project.
Open your terminal and run the following in your desired location.
npx create-next-app videos-to-boomerang-using-cloudinary
We’ve just created a new project and named it videos-to-boomerang-using-cloudinary
. The name can be whatever you want. If you would like to use advanced features such as Typescript, you should have a look at the official documentation.
Change directory to your new project
cd videos-to-boomerang-using-cloudinary
Open your project in any code editor. I recommend Visual Studio Code as it has amazing support for Javascript and React.
Let’s first install any dependencies we might have and get those out of the way. For this tutorial, we shall be using the Cloudinary SDK to communicate to cloudinary and Formidable to parse Form data. Run the following to install the two.
npm install --save cloudinary formidable
We need a secure way to define our API keys and secrets. Luckily, Next.js has built-in support for environment variables. Read more about it in the documentation.
Create a new file at the root of your project and name it .env.local
. Paste the following inside
CLOUD_NAME=YOUR_CLOUD_NAME
API_KEY=YOUR_API_KEY
API_SECRET=YOUR_API_SECRET
Replace YOUR_CLOUD_NAME
,YOUR_API_KEY
, and YOUR_API_SECRET
with the appropriate values from Cloudinary API Credentials. We got these values in the getting-cloudinary-api-credentials section.
Now we need to define functions that will handle the upload to cloudinary. Create a folder at the root of your project and name it lib
. Inside the folder, create a file called cloudinary.js
// lib/cloudinary.js
// Import the v2 api and rename it to cloudinary
import { v2 as cloudinary } 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,
});
Code language: JavaScript (javascript)
We’ve imported the v2 SDK and initialized it using our environment variables as the values for cloud_name
, api_key
, and api_secret
. Note how we rename v2
to cloudinary
for code readability.
We want to store all our videos in one folder. Add the following just below the code we added above.
// lib/cloudinary.js
// ... other code
const FOLDER_NAME = "boomerang-videos/";
Code language: JavaScript (javascript)
And now a function to get all videos in that folder. Add the following below the line we just added
// lib/cloudinary.js
export const handleGetCloudinaryUploads = () => {
return new Promise((resolve, reject) => {
cloudinary.api.resources(
{
type: "upload",
prefix: FOLDER_NAME,
resource_type: "video",
},
(error, result) => {
if (error) {
return reject(error);
}
return resolve(result);
}
);
});
};
Code language: JavaScript (javascript)
We’re exporting a function named handleGetCloudinaryUploads
. The function calls the api.resources
method on the cloudinary SDK. This gets all resources matching the options that we pass. The options include the type of resource, where we specify uploaded resources, and the prefix, which is the folder name where our videos are stored. We also tell it to only get video resources. We then either reject the promise in case of an error or resolve it with the result.
Next, a function to handle the upload. Add the following to the same file.
// lib/cloudinary.js
export const handleCloudinaryUpload = (path) => {
// 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",
// Formats we want to allow
allowed_formats: ["mp4"],
// Transformations to run on the video
transformation: [
// Set the video to be trimmed at 2 seconds
{ end_offset: "2.0" },
// Set the boomerang effect
{ effect: "boomerang" },
// Set the boomerang effect to be looped 3 times
{ effect: "loop:3" },
],
},
(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);
}
);
});
};
Code language: JavaScript (javascript)
Here we’re exporting a function named handleCloudinaryUpload
which takes in a path to the video file we want to upload. We then call the uploader.upload
method on the SDK. I will only focus on the transformation option that we pass. I will leave a link to the documentation for reference. In the transformation array, we need to pass all the transformations that we want to apply to the video before it’s stored on cloudinary servers. For the first transformation, we set an end offset of 2 seconds. This means that the video will be trimmed at 2 seconds and the boomerang applied to those 2 seconds. You can leave this transformation out, but long videos don’t make for very good boomerangs. The second and third transformations just set the boomerang effect and loop effect respectively. Please note that we also set the video to loop 3 times. This is also flexible and you can change to however many you want. Read more about all the options in the official documentation. See also.
The final function for this file will handle deleting uploaded media. Add the following below the function we just created above.
// lib/cloudinary.js
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)
This is self-explanatory. The function takes in an array of public ids for the media we want to delete. We then call the api.delete_resources
method on the SDK and pass to it our ids and specify the resource type to be video.
Moving on, we need API routes, that will receive requests from our front end.
One of the best features of Next.js and the reason why many people prefer it over plain React is support for Server Side Rendering and API routes. I highly recommend you read about Next API routes here if you’re not familiar.
Create a new file called videos.js
under the pages/api/
folder. This will allow us to make requests to the api/videos
endpoint. Paste the following code inside
// pages/api/videos.js
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { IncomingForm, Fields, Files } from "formidable";
import {
handleCloudinaryDelete,
handleCloudinaryUpload,
handleGetCloudinaryUploads,
} from "../../lib/cloudinary";
// 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 });
}
}
case "DELETE": {
try {
const { id } = req.query;
if (!id) {
throw "id param is required";
}
const result = await handleDeleteRequest(id);
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 = () => handleGetCloudinaryUploads();
const handlePostRequest = async (req) => {
// Get the form data using the parseForm function
const data = await parseForm(req);
const uploadResult = await handleCloudinaryUpload(data.files.video.path);
return uploadResult;
};
const handleDeleteRequest = async (id) => handleCloudinaryDelete([id]);
/**
*
* @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: JavaScript (javascript)
There’s quite a lot going on here. We first import the functions we created in the previous section, as well as a few things from the formidable package. Just after that, we export some custom configurations for our API route. Since we will be expecting multipart/form-data, we don’t need to use the default body-parser. Read about custom configs for API routes here.
We then have our route handler. We use a switch statement to assign different handlers for the GET, POST, and DELETE HTTP requests. For each, we either return a 200 OK status or a 400 BAD REQUEST status. For other methods that we don’t support we return a 405 status code. The handleGetRequest
and handleDeleteRequest
functions are pretty straight forward. For the handlePostRequest
, we pass the incoming request to a parseForm
function that will use formidable to parse the form data. Once we get the video file that was sent via the form data we pass its path to the handleCloudinaryUpload
function and return the upload result. You might want to have a look at formidable docs to better understand everything happening in the parseForm
function.
With this in place, all that’s remaining is our front end.
Open index.js
under the pages
folder and replace its content with the following.
// pages/index.js
import Head from "next/head";
import { useCallback, useEffect, useState } from "react";
export default function Home() {
const [videos, setVideos] = useState([]);
const [file, setFile] = useState(null);
const [loading, setLoading] = useState(false);
const getVideos = useCallback(async () => {
try {
const response = await fetch(`/api/videos`, {
method: "GET",
});
const data = await response.json();
if (!response.ok) {
throw data;
}
setVideos(data.result.resources);
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
// setLoading(false);
}
}, []);
useEffect(() => {
getVideos();
}, [getVideos]);
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;
}
e.target[0].value = "";
setFile(null);
getVideos();
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
};
const handleDownloadResource = async (resourceUrl, assetId, format) => {
try {
setLoading(true);
const response = await fetch(resourceUrl, {});
if (response.status >= 200 && response.status < 300) {
const blob = await response.blob();
const fileUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = fileUrl;
a.download = `${assetId}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
return;
}
throw await response.json();
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
};
const handleDeleteResource = async (id) => {
try {
setLoading(true);
const response = await fetch(`/api/videos/?id=${id}`, {
method: "DELETE",
});
const data = await response.json();
if (response.status >= 200 && response.status < 300) {
return getVideos();
}
throw data;
} catch (error) {
// TODO: Show error message to the user
console.error(error);
} finally {
setLoading(false);
}
};
return (
<div>
<Head>
<title>Videos to Boomerangs Using Cloudinary</title>
<meta
name="description"
content="Videos to Boomerangs Using Cloudinary"
/>
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<div className="header">
<h1>Videos to Boomerangs Using Cloudinary</h1>
</div>
<hr />
<form onSubmit={handleFormSubmit}>
<label htmlFor="video">
<b>Select video for upload</b>
</label>
<input
type="file"
name="video"
id="video"
multiple={false}
accept=".mp4"
required
disabled={loading}
onChange={(e) => {
setFile(e.target.files[0]);
}}
/>
<button type="submit" disabled={loading || !file}>
Upload Video
</button>
</form>
<hr />
{loading && (
<div className="loading">
<p>Please be patient as the action is performed...</p>
<hr />
</div>
)}
<div className="videos-wrapper">
{videos.map((video, index) => (
<div className="video-wrapper" key={`video-${index}`}>
<video
src={video.secure_url}
loop
preload="none"
controls
poster={video.secure_url.replace(".mp4", ".gif")}
></video>
<div className="video-info">
<a href={video.secure_url}>Link to video</a>
<div className="actions">
<button
disabled={loading}
onClick={() => {
handleDownloadResource(
video.secure_url,
video.asset_id,
video.format
);
}}
>
Download
</button>
<button
disabled={loading}
onClick={() => {
handleDeleteResource(video.public_id);
}}
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
</main>
<style jsx>{`
main {
}
main .header {
min-height: 100px;
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
}
main form {
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
min-height: 200px;
background-color: #ebebeb;
}
main form button {
min-width: 300px;
min-height: 50px;
margin-top: 20px;
}
main div.videos-wrapper {
width: 100%;
display: flex;
flex-flow: row wrap;
gap: 20px;
}
main div.videos-wrapper div.video-wrapper {
flex: 0 0 calc((100% / 3) - 20px);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.15);
border-radius: 5px;
background-color: #fafafa;
}
main div.videos-wrapper div.video-wrapper video {
width: 100%;
height: 300px;
border-radius: 5px 5px 0 0;
}
main div.videos-wrapper div.video-wrapper div.video-info {
height: 100px;
padding: 10px;
}
main
div.videos-wrapper
div.video-wrapper
div.video-info
div.actions
button {
margin-right: 10px;
}
`}</style>
</div>
);
}
Code language: JavaScript (javascript)
You are free to rename index.js
to index.jsx
for better code autocompletion. Most of the code here is just plain old React, Javascript, HTML, and CSS. We have a few useState
hooks at the top. These should be familiar to anyone who has done basic React. We also have a useCallback
hook and a useEffect
hook. The former creates a memoized callback function that won’t trigger unwanted re-renders and will be memorized for use across multiple re-renders. Read about it here. The latter is used to trigger side effects. In our case, we want to get all videos when the component is rendered. Read about it here. Careful with the useEffect
hook, you can end up having an infinite render loop. This is why we used useCallback
to memorize the function we want to run.
Additionally, we have methods that handle form submission, resource download, and deletion; handleFormSubmit
, handleDownloadResource
and handleDeleteResource
respectively. handleFormSubmit
makes a POST request to the /api/videos
endpoint with our form data. handleDeleteResource
makes a DELETE request to the /api/videos
endpoint with the public id of the video we want to delete. The memoized function getVideos
makes a GET request to the /api/videos
endpoint to get all uploaded videos. The rest is just some simple HTML and CSS. Have a look at CSS-in-js support for Next.js.
That has been how to apply the boomerang effect to short videos using Cloudinary and Next.js. There’s a couple of optimizations you could do for a better result. For example, you could add logic that allows the user to only select videos of a certain length so that the videos are not too long. You can find the full code on my Github