Skip to content

Meme generator using NextJS Images and videos

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.

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

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.

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

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

Cloudinary Dashboard

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.

npx 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.

cd 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

code .

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.

// pages/upload.js

const Upload = ()=>{
    return <div>
        
    </div>
}

export default Upload;
Code language: JavaScript (javascript)

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.

// pages/upload.js

const Upload = ()=>{
    // Extensions that will be allowed by file input
    const acceptedFileExtensions = [".jpg", ".jpeg", ".png", ".mp4"];

    return <div>
        <form>
            <label htmlFor="file-input"></label>
            <input 
                type="file"  
                name="file" 
                id="file-input"
                required
                accept={acceptedFileExtensions.join(",")}
                multiple={false}/>

            <label htmlFor="top-text"></label>
            <input 
                type="text"  
                name="top-text" 
                id="top-text"
                placeholder="Top text"/>

            <label htmlFor="bottom-text"></label>
            <input 
                type="text"  
                name="bottom-text" 
                id="bottom-text"
                placeholder="Bototom text"/>

            <button type="submit">
                Generate
            </button>
        </form>
    </div>
}

export default Upload;
Code language: JavaScript (javascript)

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.

// pages/upload.js

<form onSubmit={handleFormSubmit}>
    
<form>
Code language: HTML, XML (xml)

And now the actual handler method

// pages/upload.js

const Upload = () => {
   

    // Submit event handler. Takes in an event param
    const handleFormSubmit = async (e) => {
        // Prevent default form behaviour on submit
        e.preventDefault();

        try {
        // Get form data from the form. You can access the form using `e.target`
        const formData = new FormData(e.target);

        // Make a POST request to the `/api/files` endpoint with a body containing your form data
        const response = await fetch("/api/files", {
            method: "POST",
            body: formData,
        });

        // Parse the response from your request
        const data = await response.json();

        // Check if the response is successful and navigate the user to the home page.
        if (response.status >= 200 && response.status < 300) {
            return router.push("/");
        }

        throw data;
        } catch (error) {
        // TODO: Show error message to user
        console.error(error);
        }
    };

}
Code language: JavaScript (javascript)

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

// pages/api/files.js

export default async (req, res) => {
    return res.status(200).json('Hello world');
}
Code language: JavaScript (javascript)

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

// pages/api/files.js
import {
  handleCloudinaryUpload,
  parseForm,
} from "../../lib/files";

export const config = {
  api: {
    bodyParser: false,
  },
};

export default async (req, res) => {
    switch (req.method) {
        case "GET": {

        }
        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": {
            
        }
        default: {
            return res.status(405).json({ message: "Method not allowed" });
        }
    }
};

const handlePostRequest = async (req) => {
  const data = await parseForm(req);

  const result = await handleCloudinaryUpload(data?.files?.file, {
    topText: data?.fields?.["top-text"],
    bottomText: data?.fields?.["bottom-text"],
  });

  return result;
};
Code language: JavaScript (javascript)

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.

// utils/cloudinary.js

import { v2 as cloudinary } from "cloudinary";

cloudinary.config({
  cloud_name: process.env.CLOUD_NAME,
  api_key: process.env.API_KEY,
  api_secret: process.env.API_SECRET,
});

export default cloudinary;
Code language: JavaScript (javascript)

We also need to install the cloudinary package from npm.

npm 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.

# .env.local

CLOUD_NAME=YOUR_CLOUD_NAME
API_KEY=YOUR_API_KEY
API_SECRET=YOUR_API_SECRET
Code language: PHP (php)

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.

// utils/media.js

/**
 * Checks if a file is a video or not by checking if the `type` field ends with mp4
 * @param {File} file
 * @returns {boolean}
 */
export const isVideo = (file) => {
  return file.type.endsWith("mp4");
};

Code language: JavaScript (javascript)

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.

npm install -S formidable

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

// lib/files.js

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

/**
 *
 * @param {*} req
 * @returns {Promise<{ fields:Fields; files:Files; }>}
 */
export 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)

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.

// lib/files.js`

import cloudinary from "../utils/cloudinary";
import { isVideo } from "../utils/media";

const FOLDER_NAME = "memes";

/**
 * Uploads a file to cloudinary
 * @param {File} file
 * @param {{topText:string;bottomText:string}} options
 * @returns
 */
export const handleCloudinaryUpload = (file, { topText, bottomText }) => {
  return new Promise((resolve, reject) => {
    const fileIsVideo = isVideo(file);

    cloudinary.uploader.upload(
      file.path,
      {
        // Folder to store resource in
        folder: `${FOLDER_NAME}/`,
        // Tags that describe the resource
        tags: ["memes"],
        // Type of resource. We leave it to cloudinary to determine but on the front end we only allow images and videos
        resource_type: "auto",
        // Only allow these formats
        allowed_formats: ["jpg", "jpeg", "png", "mp4"],

        // Array of transformations/manipulation that will be applied to the image by default
        transformation: [
          /// We're going to pad the resource to an aspect ratio of 16:9.
          {
            background: "auto",
            crop: "pad",
            aspect_ratio: "16:9",
          },
          // If the file is a video
          ...(fileIsVideo
            ? [
                { format: "gif" },
                {
                  effect: "loop:3",
                },
              ]
            : []),
          // If top text is not null
          ...(topText
            ? [
                {
                  // Align the text layer towards the top
                  gravity: "north",
                  // Space of 5% from the top
                  y: "0.05",
                  // Text stroke/border
                  border: "10px_solid_black",
                  // Text color
                  color: "white",
                  overlay: {
                    font_family: "Arial",
                    font_size: topText.length <= 20 ? 50 : 40,
                    font_weight: "bold",
                    font_style: "italic",
                    stroke: "stroke",
                    letter_spacing: 10,
                    text: topText,
                  },
                },
              ]
            : []),
          // If bottom text is not null
          ...(bottomText
            ? [
                {
                  // Align the text layer towards the bottom
                  gravity: "south",
                  // Space of 5% from the bottom
                  y: "0.05",
                  // Text stroke/border
                  border: "10px_solid_black",
                  // Text color
                  color: "white",
                  overlay: {
                    font_family: "Arial",
                    font_size: bottomText.length <= 20 ? 50 : 40,
                    font_weight: "bold",
                    font_style: "italic",
                    stroke: "stroke",
                    letter_spacing: 10,
                    text: bottomText,
                  },
                },
              ]
            : []),
        ],
      },
      (error, result) => {
        if (error) {
          return reject(error);
        }

        return resolve(result);
      }
    );
  });
};
Code language: JavaScript (javascript)

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.

// lib/files.js

/**
 * Get cloudinary uploads in the `memes` folder based on resource type
 * @param {"image"|"video"} resource_type
 * @returns {Promise}
 */
export const handleGetCloudinaryUploads = (resource_type) => {
  return new Promise((resolve, reject) => {
    cloudinary.api.resources(
      {
        type: "upload",
        prefix: FOLDER_NAME,
        resource_type,
      },
      (error, result) => {
        if (error) {
          return reject(error);
        }

        return resolve(result);
      }
    );
  });
};
Code language: JavaScript (javascript)

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.


/**
 *
 * @param {string[]} ids - Array of Public IDs of resources to delete
 * @param {"image"|"video"} type - Type of resources
 * @returns
 */
export const handleCloudinaryDelete = async (ids, type) => {
  return new Promise((resolve, reject) => {
    cloudinary.api.delete_resources(
      ids,
      {
        resource_type: type,
      },
      (error, result) => {
        if (error) {
          return reject(error);
        }

        return resolve(result);
      }
    );
  });
};
Code language: JavaScript (javascript)

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

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

import {
  handleCloudinaryDelete,
  handleCloudinaryUpload,
  handleGetCloudinaryUploads,
  parseForm,
} from "../../lib/files";

export const config = {
  api: {
    bodyParser: false,
  },
};

export default async (req, res) => {
  switch (req.method) {
    case "GET": {
      try {
        // Extract the type query param from the request
        const { type } = req.query;

        if (!type) {
          throw "type param is required";
        }

        const result = await handleGetRequest(type);

        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 {
        // Extract the type and id query params from the request
        const { type, id } = req.query;

        if (!id || !type) {
          throw "id and type params are required";
        }

        const result = await handleDeleteRequest(id, type);

        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 = async (type) => {
  const result = await handleGetCloudinaryUploads(type);

  return result;
};

const handlePostRequest = async (req) => {
  const data = await parseForm(req);

  const result = await handleCloudinaryUpload(data?.files?.file, {
    topText: data?.fields?.["top-text"],
    bottomText: data?.fields?.["bottom-text"],
  });

  return result;
};

const handleDeleteRequest = async (id, type) => {
  const result = await handleCloudinaryDelete([id], type);

  return result;
};
Code language: JavaScript (javascript)

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

// pages/index.js

export default function Home() {
  const [resources, setResources] = useState([]);

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

  useEffect(() => {
    refresh();
  }, []);

  const refresh = async () => {
    try {
      const [imagesResponse, videosResponse] = await Promise.all([
        fetch("/api/files/?type=image", {
          method: "GET",
        }),
        fetch("/api/files/?type=video", {
          method: "GET",
        }),
      ]);

      const [imagesData, videosData] = await Promise.all([
        imagesResponse.json(),
        videosResponse.json(),
      ]);

      let allResources = [];

      if (imagesResponse.status >= 200 && imagesResponse.status < 300) {
        allResources = [...allResources, ...imagesData.result.resources];
      } else {
        throw data;
      }

      if (videosResponse.status >= 200 && videosResponse.status < 300) {
        allResources = [...allResources, ...videosData.result.resources];
      } else {
        throw data;
      }

      setResources(allResources);
    } catch (error) {
      // TODO: Show error message to user
      console.error(error);
    }
  };

  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 user
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  const handleDeleteResource = async (id, type) => {
    try {
      setLoading(true);
      const response = await fetch(`/api/files/?id=${id}&type=${type}`, {
        method: "DELETE",
      });

      const data = await response.json();

      if (response.status >= 200 && response.status < 300) {
        return refresh();
      }

      throw data;
    } catch (error) {
      // TODO: Show error message to user
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (<div>
    {resources.length} Resources Uploaded
  </div>);
}
Code language: JavaScript (javascript)

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.

return (
    <div className="main">
        <nav>
            <h1>Meme Generator</h2>
            <div>
                <Link href="/">
                    <a>
                    Home
                    </a>
                </Link>

                <Link href="/upload">
                    <a>
                    Upload Photo/Video
                    </a>
                </Link>
            </div>
        </nav>
        {resources.length} Resources Uploaded
        <div className="resources-wrapper">
            {resources.map((resource, index) => {
                const isVideo = resource.resource_type === "video";

                let resourceUrl = resource.secure_url;

                if (isVideo) {
                resourceUrl = resource.secure_url.replace(".mp4", ".gif");
                }

                return (
                    <div className="resource-wrapper" key={index}>
                        <div className="resource">
                            <Image
                                className="image"
                                src={resourceUrl}
                                layout="responsive"
                                alt={resourceUrl}
                                width={resource.width}
                                height={resource.height}
                            ></Image>
                        </div>
                        <div className="actions">
                            <button
                                disabled={loading}
                                onClick={() => {
                                handleDownloadResource(
                                    resourceUrl,
                                    resource.asset_id,
                                    isVideo ? "gif" : resource.format
                                );
                                }}
                            >
                                Download
                            </button>
                            <button
                                disabled={loading}
                                onClick={() => {
                                handleDeleteResource(
                                    resource.public_id,
                                    isVideo ? "video" : "image"
                                );
                                }}
                            >
                                Delete
                            </button>
                        </div>
                    </div>
                );
            })}
        </div>
    </div>
);
Code language: JavaScript (javascript)

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.

Back to top

Featured Post