Meme generator using NextJS Images and videos

Eugene Musebe

Meme generator using Next.js Images and videos.

Introduction

In this brief post, I will be taking you through creating a meme generator using Next.js and Cloudinary. We will use Cloudinary to save the videos and images. The post assumes that you have a working knowledge of Javascript and Next.js.

TL;DR

Here's a summary of what will be covered.

  1. Select image/video files from the device
  2. Upload image/video using Next.js api routes
  3. Add text to top/bottom of image/video using cloudinary
  4. Upload the image/video to cloudinary
  5. View the result from cloudinary
  6. Download the result
  7. Delete the result

To test the final product visit the codesandbox below :

The corresponding GitHub repository can be found here

Getting Started

Prerequisites

Installing Node.js and NPM

There are tons of tutorials on how to do this. You can check out the official Node.js website on installation and adding the path to your environment variables. You could also check out NVM, a version manager for node. If you are a power user and might be switching between node versions often, I would recommend the latter.

A code editor

You will need a code editor. Any code editor will do. Check out Visual Studio Code, which has great support for javascript and node.

Cloudinary account and API keys

You will need some API credentials before making requests to Cloudinary. Luckily, you can get started with a free account immediately. Head over to Cloudinary and sign in or sign up for a free account. Once done with that, head over to your console. At the top left corner of your console, you'll notice your account details. Take note of your Cloud name API Key and API Secret. We will need those later

Getting our hands dirty

We start off by creating a new Next.js project. Fire up your terminal/command line and navigate to your desired project's parent folder. Run the following command to create a new project.

1npx create-next-app

You can check out different installation options on the official docs.

Follow the prompts on your terminal and change the directory to the project folder. I named my project meme-generator.

1cd meme-generator

Great! We have a project that's ready to go. Remember to also open the project in your code editor. If you are using Visual Studio Code you can run the following command on your terminal

1code .

And we're now ready to get started.

How do we select image/video files from the device? We're going to be using a HTML file input element and uploading the selected file to the backend using api routes. Our client-side will have two pages. That's the upload page and the home page where we will show all uploaded images and videos.

Create a new file in the pages/ folder and name it upload.js. This will hold our upload page so go ahead and export a default functional component.

1// pages/upload.js
2
3const Upload = ()=>{
4 return <div>
5
6 </div>
7}
8
9export default Upload;

We now need a form and a few inputs. Let us think to go over the requirements before proceeding. The generated meme should have some text at either the top or the bottom. We will need a required file input for selecting images/videos from the device and two optional text inputs for the top and bottom text. Let's implement that.

1// pages/upload.js
2
3const Upload = ()=>{
4 // Extensions that will be allowed by file input
5 const acceptedFileExtensions = [".jpg", ".jpeg", ".png", ".mp4"];
6
7 return <div>
8 <form>
9 <label htmlFor="file-input"></label>
10 <input
11 type="file"
12 name="file"
13 id="file-input"
14 required
15 accept={acceptedFileExtensions.join(",")}
16 multiple={false}/>
17
18 <label htmlFor="top-text"></label>
19 <input
20 type="text"
21 name="top-text"
22 id="top-text"
23 placeholder="Top text"/>
24
25 <label htmlFor="bottom-text"></label>
26 <input
27 type="text"
28 name="bottom-text"
29 id="bottom-text"
30 placeholder="Bototom text"/>
31
32 <button type="submit">
33 Generate
34 </button>
35 </form>
36 </div>
37}
38
39export default Upload;

With that in place, the only thing left is to handle the form submission. Let's create a handler for that. Add the onSubmit event handler to your form. This is the same form that we just created above in the pages/upload.js file.

1// pages/upload.js
2
3<form onSubmit={handleFormSubmit}>
4
5<form>

And now the actual handler method

1// pages/upload.js
2
3const Upload = () => {
4
5
6 // Submit event handler. Takes in an event param
7 const handleFormSubmit = async (e) => {
8 // Prevent default form behaviour on submit
9 e.preventDefault();
10
11 try {
12 // Get form data from the form. You can access the form using `e.target`
13 const formData = new FormData(e.target);
14
15 // Make a POST request to the `/api/files` endpoint with a body containing your form data
16 const response = await fetch("/api/files", {
17 method: "POST",
18 body: formData,
19 });
20
21 // Parse the response from your request
22 const data = await response.json();
23
24 // Check if the response is successful and navigate the user to the home page.
25 if (response.status >= 200 && response.status < 300) {
26 return router.push("/");
27 }
28
29 throw data;
30 } catch (error) {
31 // TODO: Show error message to user
32 console.error(error);
33 }
34 };
35
36}

Let's go over that. We first define our handle method as handleFormSubmit. The method takes in an onSubmit event parameter. We then get our form data and post that to our /api/files endpoint which we will be creating in the next section. If the post request is successful we navigate to the home page to view the uploaded files.

Let us look at how to upload image/video files using Next.js api routes. We already have the code to select the file and handle the form submission. Now we need a way to receive the file. Next.js api routes work similarly to traditional REST api routes. Read more about Next.js api routes in the official documentation.


Create a new file inside the pages/api/ folder and call it files.js. Paste the following code inside

1// pages/api/files.js
2
3export default async (req, res) => {
4 return res.status(200).json('Hello world');
5}

Let's modify this to handle different HTTP methods including our upload.

1// pages/api/files.js
2import {
3 handleCloudinaryUpload,
4 parseForm,
5} from "../../lib/files";
6
7export const config = {
8 api: {
9 bodyParser: false,
10 },
11};
12
13export default async (req, res) => {
14 switch (req.method) {
15 case "GET": {
16
17 }
18 case "POST": {
19 try {
20 const result = await handlePostRequest(req);
21
22 return res.status(200).json({ message: "Success", result });
23 } catch (error) {
24 return res.status(400).json({ message: "Error", error });
25 }
26 }
27 case "DELETE": {
28
29 }
30 default: {
31 return res.status(405).json({ message: "Method not allowed" });
32 }
33 }
34};
35
36const handlePostRequest = async (req) => {
37 const data = await parseForm(req);
38
39 const result = await handleCloudinaryUpload(data?.files?.file, {
40 topText: data?.fields?.["top-text"],
41 bottomText: data?.fields?.["bottom-text"],
42 });
43
44 return result;
45};

At the top, we import a few methods that we will create shortly. Just below that, we disable the default Next.js body-parser using a custom configuration. This is because we need to parse some Form data. Check out the official docs on some other custom configuration options. We then proceed to implement the handler for POST requests.

Create a new folder called utils at the root of the project folder. Inside the utils folder, create a new file called cloudinary.js and another called media.js. This folder holds methods that are common/shared among the whole project.

Inside the utils/cloudinary.js file, let us set up to initialize the cloudinary SDK.

1// utils/cloudinary.js
2
3import { v2 as cloudinary } from "cloudinary";
4
5cloudinary.config({
6 cloud_name: process.env.CLOUD_NAME,
7 api_key: process.env.API_KEY,
8 api_secret: process.env.API_SECRET,
9});
10
11export default cloudinary;

We also need to install the cloudinary package from npm.

1npm install -S cloudinary

You will also notice that we used environment variables to reference our cloudinary api keys. Let's define those next. Create a .env.local file at the root of your project and paste in the following.

1# .env.local
2
3CLOUD_NAME=YOUR_CLOUD_NAME
4API_KEY=YOUR_API_KEY
5API_SECRET=YOUR_API_SECRET

Replace YOUR_CLOUD_NAME YOUR_API_KEY and YOUR_API_SECRET with the appropriate values from the Prerequisites > Cloudinary account and API keys section.

Let's also define a simple method to check if a file is a video or an image inside the utils/media.js file.

1// utils/media.js
2
3/**
4 * Checks if a file is a video or not by checking if the `type` field ends with mp4
5 * @param {File} file
6 * @returns {boolean}
7 */
8export const isVideo = (file) => {
9 return file.type.endsWith("mp4");
10};

That's all for the utils/ folder.


Moving on, let us implement the missing parseForm and handleCloudinaryUpload methods. Create a new folder called lib at the root of your project folder. Inside the lib folder create a new file called files.js. This file will hold all methods common to the api/files route.

Next, we need to define a method that will handle the parsing of the form data that we receive when uploading our files. To make this easier, we will use a package called Formidable. Let's install that.

1npm install -S formidable

You can now open lib/files.js and paste the following code.

1// lib/files.js
2
3import { IncomingForm, Fields, Files } from "formidable";
4
5/**
6 *
7 * @param {*} req
8 * @returns {Promise<{ fields:Fields; files:Files; }>}
9 */
10export const parseForm = (req) => {
11 return new Promise((resolve, reject) => {
12 const form = new IncomingForm({ keepExtensions: true, multiples: true });
13
14 form.parse(req, (error, fields, files) => {
15 if (error) {
16 return reject(error);
17 }
18
19 return resolve({ fields, files });
20 });
21 });
22};

Inside our parseForm method we first create a form using Formidable's IncomingForm class. We then parse the form and resolve a promise with the fields and files from the form or reject with an error if any. Read more about parsing forms using Formidable from the official documentation.

We also need a method to handle the upload of files to cloudinary. Inside the same file,lib/files.js, create a new method as follows.

1// lib/files.js`
2
3import cloudinary from "../utils/cloudinary";
4import { isVideo } from "../utils/media";
5
6const FOLDER_NAME = "memes";
7
8/**
9 * Uploads a file to cloudinary
10 * @param {File} file
11 * @param {{topText:string;bottomText:string}} options
12 * @returns
13 */
14export const handleCloudinaryUpload = (file, { topText, bottomText }) => {
15 return new Promise((resolve, reject) => {
16 const fileIsVideo = isVideo(file);
17
18 cloudinary.uploader.upload(
19 file.path,
20 {
21 // Folder to store resource in
22 folder: `${FOLDER_NAME}/`,
23 // Tags that describe the resource
24 tags: ["memes"],
25 // Type of resource. We leave it to cloudinary to determine but on the front end we only allow images and videos
26 resource_type: "auto",
27 // Only allow these formats
28 allowed_formats: ["jpg", "jpeg", "png", "mp4"],
29
30 // Array of transformations/manipulation that will be applied to the image by default
31 transformation: [
32 /// We're going to pad the resource to an aspect ratio of 16:9.
33 {
34 background: "auto",
35 crop: "pad",
36 aspect_ratio: "16:9",
37 },
38 // If the file is a video
39 ...(fileIsVideo
40 ? [
41 { format: "gif" },
42 {
43 effect: "loop:3",
44 },
45 ]
46 : []),
47 // If top text is not null
48 ...(topText
49 ? [
50 {
51 // Align the text layer towards the top
52 gravity: "north",
53 // Space of 5% from the top
54 y: "0.05",
55 // Text stroke/border
56 border: "10px_solid_black",
57 // Text color
58 color: "white",
59 overlay: {
60 font_family: "Arial",
61 font_size: topText.length <= 20 ? 50 : 40,
62 font_weight: "bold",
63 font_style: "italic",
64 stroke: "stroke",
65 letter_spacing: 10,
66 text: topText,
67 },
68 },
69 ]
70 : []),
71 // If bottom text is not null
72 ...(bottomText
73 ? [
74 {
75 // Align the text layer towards the bottom
76 gravity: "south",
77 // Space of 5% from the bottom
78 y: "0.05",
79 // Text stroke/border
80 border: "10px_solid_black",
81 // Text color
82 color: "white",
83 overlay: {
84 font_family: "Arial",
85 font_size: bottomText.length <= 20 ? 50 : 40,
86 font_weight: "bold",
87 font_style: "italic",
88 stroke: "stroke",
89 letter_spacing: 10,
90 text: bottomText,
91 },
92 },
93 ]
94 : []),
95 ],
96 },
97 (error, result) => {
98 if (error) {
99 return reject(error);
100 }
101
102 return resolve(result);
103 }
104 );
105 });
106};

That might be a bit overwhelming, let's go over it. At the top of the file, just after the imports, we define a folder where our files will be stored on cloudinary. We then define our handleCloudinaryUpload that takes in the file to upload and an options object. The options object contains the text to show at the top and at the bottom. Inside the method, we first determine if the file is a video or an image. We then proceed to use Cloudinary's upload api to upload our files with a few options. Most of the options are common and self-explanatory. The transformation option is of particular interest. Using this option we define an array of transformation that will be applied to the resource before saving it. The first transformation we do is to pad the image/video so that it ends up with an aspect ratio of 16:9. We do this to ensure that long captions have enough space and are not trimmed/clipped. The second transformation is applied only to video files. The third and fourth transformations are applied only if the top text or bottom text is supplied. We give the text a white color with a black solid stroke to ensure it is visible on all backgrounds. We either resolve with a result or reject with an error. Read more about transformations from the official documentation

While we're still in the lib/files.js file, we should also create methods to handle getting and deleting our uploads.

1// lib/files.js
2
3/**
4 * Get cloudinary uploads in the `memes` folder based on resource type
5 * @param {"image"|"video"} resource_type
6 * @returns {Promise}
7 */
8export const handleGetCloudinaryUploads = (resource_type) => {
9 return new Promise((resolve, reject) => {
10 cloudinary.api.resources(
11 {
12 type: "upload",
13 prefix: FOLDER_NAME,
14 resource_type,
15 },
16 (error, result) => {
17 if (error) {
18 return reject(error);
19 }
20
21 return resolve(result);
22 }
23 );
24 });
25};

The handleGetCloudinaryUploads methods uses Cloudinary's Admin Api to fetch all uploads of a certain type and that are stored inside our defined folder. The FOLDER_NAME in our case is memes.

Let's also create one for handling deletion.

1/**
2 *
3 * @param {string[]} ids - Array of Public IDs of resources to delete
4 * @param {"image"|"video"} type - Type of resources
5 * @returns
6 */
7export const handleCloudinaryDelete = async (ids, type) => {
8 return new Promise((resolve, reject) => {
9 cloudinary.api.delete_resources(
10 ids,
11 {
12 resource_type: type,
13 },
14 (error, result) => {
15 if (error) {
16 return reject(error);
17 }
18
19 return resolve(result);
20 }
21 );
22 });
23};

The handleCloudinaryDelete method takes in two parameters. The first is an array of public ids belonging to the resources we want to delete. The second is the type of resources.

We're now done with the lib folder. Let's head back to our pages/api/files.js file to add handlers for the GET and DELETE HTTP methods. Modify the code as follows

1// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2
3import {
4 handleCloudinaryDelete,
5 handleCloudinaryUpload,
6 handleGetCloudinaryUploads,
7 parseForm,
8} from "../../lib/files";
9
10export const config = {
11 api: {
12 bodyParser: false,
13 },
14};
15
16export default async (req, res) => {
17 switch (req.method) {
18 case "GET": {
19 try {
20 // Extract the type query param from the request
21 const { type } = req.query;
22
23 if (!type) {
24 throw "type param is required";
25 }
26
27 const result = await handleGetRequest(type);
28
29 return res.status(200).json({ message: "Success", result });
30 } catch (error) {
31 return res.status(400).json({ message: "Error", error });
32 }
33 }
34 case "POST": {
35 try {
36 const result = await handlePostRequest(req);
37
38 return res.status(200).json({ message: "Success", result });
39 } catch (error) {
40 return res.status(400).json({ message: "Error", error });
41 }
42 }
43 case "DELETE": {
44 try {
45 // Extract the type and id query params from the request
46 const { type, id } = req.query;
47
48 if (!id || !type) {
49 throw "id and type params are required";
50 }
51
52 const result = await handleDeleteRequest(id, type);
53
54 return res.status(200).json({ message: "Success", result });
55 } catch (error) {
56 return res.status(400).json({ message: "Error", error });
57 }
58 }
59 default: {
60 return res.status(405).json({ message: "Method not allowed" });
61 }
62 }
63};
64
65const handleGetRequest = async (type) => {
66 const result = await handleGetCloudinaryUploads(type);
67
68 return result;
69};
70
71const handlePostRequest = async (req) => {
72 const data = await parseForm(req);
73
74 const result = await handleCloudinaryUpload(data?.files?.file, {
75 topText: data?.fields?.["top-text"],
76 bottomText: data?.fields?.["bottom-text"],
77 });
78
79 return result;
80};
81
82const handleDeleteRequest = async (id, type) => {
83 const result = await handleCloudinaryDelete([id], type);
84
85 return result;
86};

We now have all methods necessary for uploading, fetching, and deleting our memes. Let's move on to the client-side. We will show our generated memes on the home page. Open pages/index.js and replace the code inside with the following

1// pages/index.js
2
3export default function Home() {
4 const [resources, setResources] = useState([]);
5
6 const [loading, setLoading] = useState(false);
7
8 useEffect(() => {
9 refresh();
10 }, []);
11
12 const refresh = async () => {
13 try {
14 const [imagesResponse, videosResponse] = await Promise.all([
15 fetch("/api/files/?type=image", {
16 method: "GET",
17 }),
18 fetch("/api/files/?type=video", {
19 method: "GET",
20 }),
21 ]);
22
23 const [imagesData, videosData] = await Promise.all([
24 imagesResponse.json(),
25 videosResponse.json(),
26 ]);
27
28 let allResources = [];
29
30 if (imagesResponse.status >= 200 && imagesResponse.status < 300) {
31 allResources = [...allResources, ...imagesData.result.resources];
32 } else {
33 throw data;
34 }
35
36 if (videosResponse.status >= 200 && videosResponse.status < 300) {
37 allResources = [...allResources, ...videosData.result.resources];
38 } else {
39 throw data;
40 }
41
42 setResources(allResources);
43 } catch (error) {
44 // TODO: Show error message to user
45 console.error(error);
46 }
47 };
48
49 const handleDownloadResource = async (resourceUrl, assetId, format) => {
50 try {
51 setLoading(true);
52 const response = await fetch(resourceUrl, {});
53
54 if (response.status >= 200 && response.status < 300) {
55 const blob = await response.blob();
56
57 const fileUrl = URL.createObjectURL(blob);
58
59 const a = document.createElement("a");
60 a.href = fileUrl;
61 a.download = `${assetId}.${format}`;
62 document.body.appendChild(a);
63 a.click();
64 a.remove();
65 return;
66 }
67
68 throw await response.json();
69 } catch (error) {
70 // TODO: Show error message to user
71 console.error(error);
72 } finally {
73 setLoading(false);
74 }
75 };
76
77 const handleDeleteResource = async (id, type) => {
78 try {
79 setLoading(true);
80 const response = await fetch(`/api/files/?id=${id}&type=${type}`, {
81 method: "DELETE",
82 });
83
84 const data = await response.json();
85
86 if (response.status >= 200 && response.status < 300) {
87 return refresh();
88 }
89
90 throw data;
91 } catch (error) {
92 // TODO: Show error message to user
93 console.error(error);
94 } finally {
95 setLoading(false);
96 }
97 };
98
99 return (<div>
100 {resources.length} Resources Uploaded
101 </div>);
102}

At the top, we define a state variable to hold our uploaded resources using React state hooks. We also define a loading state. We then use the React useEffect hook with no dependencies to fetch our initial data. When fetching our data in the refresh method we make two separate api calls to our /api/files/ endpoint. One for image resources and another for video resources. We then combine the responses and update our resources state.

The handleDownloadResource method takes in the resource url, asset id, and format of the resource. We get the resource using native fetch and convert the response to a blob if successful. We then create a link with the object url for the received blob and download the resource.

The handleDeleteResource method takes in the resource's public id and type. We then make a DELETE call to our /api/files endpoint. If the response is successful we refresh our data.

Finally, let us create the view. For this page, we will have a column flowing grid with each resource as its own item. In the return method, replace the existing div with the following code.

1return (
2 <div className="main">
3 <nav>
4 <h1>Meme Generator</h2>
5 <div>
6 <Link href="/">
7 <a>
8 Home
9 </a>
10 </Link>
11
12 <Link href="/upload">
13 <a>
14 Upload Photo/Video
15 </a>
16 </Link>
17 </div>
18 </nav>
19 {resources.length} Resources Uploaded
20 <div className="resources-wrapper">
21 {resources.map((resource, index) => {
22 const isVideo = resource.resource_type === "video";
23
24 let resourceUrl = resource.secure_url;
25
26 if (isVideo) {
27 resourceUrl = resource.secure_url.replace(".mp4", ".gif");
28 }
29
30 return (
31 <div className="resource-wrapper" key={index}>
32 <div className="resource">
33 <Image
34 className="image"
35 src={resourceUrl}
36 layout="responsive"
37 alt={resourceUrl}
38 width={resource.width}
39 height={resource.height}
40 ></Image>
41 </div>
42 <div className="actions">
43 <button
44 disabled={loading}
45 onClick={() => {
46 handleDownloadResource(
47 resourceUrl,
48 resource.asset_id,
49 isVideo ? "gif" : resource.format
50 );
51 }}
52 >
53 Download
54 </button>
55 <button
56 disabled={loading}
57 onClick={() => {
58 handleDeleteResource(
59 resource.public_id,
60 isVideo ? "video" : "image"
61 );
62 }}
63 >
64 Delete
65 </button>
66 </div>
67 </div>
68 );
69 })}
70 </div>
71 </div>
72);

Inside the div with class .resources-wrapper we map through our resources and for every resource return a div that wraps our actual resource and a few buttons. If the resource is a video we simply show an image using the resource url. However, if the resource is a video, we first change the resource's extension from .mp4 to .gif and then show an image with the url modified to have .gif at the end instead of .mp4. By doing this, Cloudinary converts our video to a gif. Read more about this from the official documentation. Please consider using the Image component that comes bundled with Next.js for an optimized experience. Read more about it from the official documentation. For the buttons, we just have a download button and a delete button.

We now have a simple, fully functioning meme generator.

Refferences

Eugene Musebe

Software Developer

I’m a full-stack software developer, content creator, and tech community builder based in Nairobi, Kenya. I am addicted to learning new technologies and loves working with like-minded people.