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.
- Select image/video files from the device
- Upload image/video using Next.js api routes
- Add text to top/bottom of image/video using cloudinary
- Upload the image/video to cloudinary
- View the result from cloudinary
- Download the result
- 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.js23const Upload = ()=>{4 return <div>56 </div>7}89export 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.js23const Upload = ()=>{4 // Extensions that will be allowed by file input5 const acceptedFileExtensions = [".jpg", ".jpeg", ".png", ".mp4"];67 return <div>8 <form>9 <label htmlFor="file-input"></label>10 <input11 type="file"12 name="file"13 id="file-input"14 required15 accept={acceptedFileExtensions.join(",")}16 multiple={false}/>1718 <label htmlFor="top-text"></label>19 <input20 type="text"21 name="top-text"22 id="top-text"23 placeholder="Top text"/>2425 <label htmlFor="bottom-text"></label>26 <input27 type="text"28 name="bottom-text"29 id="bottom-text"30 placeholder="Bototom text"/>3132 <button type="submit">33 Generate34 </button>35 </form>36 </div>37}3839export 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.js23<form onSubmit={handleFormSubmit}>45<form>
And now the actual handler method
1// pages/upload.js23const Upload = () => {456 // Submit event handler. Takes in an event param7 const handleFormSubmit = async (e) => {8 // Prevent default form behaviour on submit9 e.preventDefault();1011 try {12 // Get form data from the form. You can access the form using `e.target`13 const formData = new FormData(e.target);1415 // Make a POST request to the `/api/files` endpoint with a body containing your form data16 const response = await fetch("/api/files", {17 method: "POST",18 body: formData,19 });2021 // Parse the response from your request22 const data = await response.json();2324 // 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 }2829 throw data;30 } catch (error) {31 // TODO: Show error message to user32 console.error(error);33 }34 };3536}
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.js23export 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.js2import {3 handleCloudinaryUpload,4 parseForm,5} from "../../lib/files";67export const config = {8 api: {9 bodyParser: false,10 },11};1213export default async (req, res) => {14 switch (req.method) {15 case "GET": {1617 }18 case "POST": {19 try {20 const result = await handlePostRequest(req);2122 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": {2829 }30 default: {31 return res.status(405).json({ message: "Method not allowed" });32 }33 }34};3536const handlePostRequest = async (req) => {37 const data = await parseForm(req);3839 const result = await handleCloudinaryUpload(data?.files?.file, {40 topText: data?.fields?.["top-text"],41 bottomText: data?.fields?.["bottom-text"],42 });4344 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.js23import { v2 as cloudinary } from "cloudinary";45cloudinary.config({6 cloud_name: process.env.CLOUD_NAME,7 api_key: process.env.API_KEY,8 api_secret: process.env.API_SECRET,9});1011export 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.local23CLOUD_NAME=YOUR_CLOUD_NAME4API_KEY=YOUR_API_KEY5API_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.js23/**4 * Checks if a file is a video or not by checking if the `type` field ends with mp45 * @param {File} file6 * @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.js23import { IncomingForm, Fields, Files } from "formidable";45/**6 *7 * @param {*} req8 * @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 });1314 form.parse(req, (error, fields, files) => {15 if (error) {16 return reject(error);17 }1819 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`23import cloudinary from "../utils/cloudinary";4import { isVideo } from "../utils/media";56const FOLDER_NAME = "memes";78/**9 * Uploads a file to cloudinary10 * @param {File} file11 * @param {{topText:string;bottomText:string}} options12 * @returns13 */14export const handleCloudinaryUpload = (file, { topText, bottomText }) => {15 return new Promise((resolve, reject) => {16 const fileIsVideo = isVideo(file);1718 cloudinary.uploader.upload(19 file.path,20 {21 // Folder to store resource in22 folder: `${FOLDER_NAME}/`,23 // Tags that describe the resource24 tags: ["memes"],25 // Type of resource. We leave it to cloudinary to determine but on the front end we only allow images and videos26 resource_type: "auto",27 // Only allow these formats28 allowed_formats: ["jpg", "jpeg", "png", "mp4"],2930 // Array of transformations/manipulation that will be applied to the image by default31 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 video39 ...(fileIsVideo40 ? [41 { format: "gif" },42 {43 effect: "loop:3",44 },45 ]46 : []),47 // If top text is not null48 ...(topText49 ? [50 {51 // Align the text layer towards the top52 gravity: "north",53 // Space of 5% from the top54 y: "0.05",55 // Text stroke/border56 border: "10px_solid_black",57 // Text color58 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 null72 ...(bottomText73 ? [74 {75 // Align the text layer towards the bottom76 gravity: "south",77 // Space of 5% from the bottom78 y: "0.05",79 // Text stroke/border80 border: "10px_solid_black",81 // Text color82 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 }101102 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.js23/**4 * Get cloudinary uploads in the `memes` folder based on resource type5 * @param {"image"|"video"} resource_type6 * @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 }2021 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 delete4 * @param {"image"|"video"} type - Type of resources5 * @returns6 */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 }1819 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/introduction23import {4 handleCloudinaryDelete,5 handleCloudinaryUpload,6 handleGetCloudinaryUploads,7 parseForm,8} from "../../lib/files";910export const config = {11 api: {12 bodyParser: false,13 },14};1516export default async (req, res) => {17 switch (req.method) {18 case "GET": {19 try {20 // Extract the type query param from the request21 const { type } = req.query;2223 if (!type) {24 throw "type param is required";25 }2627 const result = await handleGetRequest(type);2829 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);3738 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 request46 const { type, id } = req.query;4748 if (!id || !type) {49 throw "id and type params are required";50 }5152 const result = await handleDeleteRequest(id, type);5354 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};6465const handleGetRequest = async (type) => {66 const result = await handleGetCloudinaryUploads(type);6768 return result;69};7071const handlePostRequest = async (req) => {72 const data = await parseForm(req);7374 const result = await handleCloudinaryUpload(data?.files?.file, {75 topText: data?.fields?.["top-text"],76 bottomText: data?.fields?.["bottom-text"],77 });7879 return result;80};8182const handleDeleteRequest = async (id, type) => {83 const result = await handleCloudinaryDelete([id], type);8485 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.js23export default function Home() {4 const [resources, setResources] = useState([]);56 const [loading, setLoading] = useState(false);78 useEffect(() => {9 refresh();10 }, []);1112 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 ]);2223 const [imagesData, videosData] = await Promise.all([24 imagesResponse.json(),25 videosResponse.json(),26 ]);2728 let allResources = [];2930 if (imagesResponse.status >= 200 && imagesResponse.status < 300) {31 allResources = [...allResources, ...imagesData.result.resources];32 } else {33 throw data;34 }3536 if (videosResponse.status >= 200 && videosResponse.status < 300) {37 allResources = [...allResources, ...videosData.result.resources];38 } else {39 throw data;40 }4142 setResources(allResources);43 } catch (error) {44 // TODO: Show error message to user45 console.error(error);46 }47 };4849 const handleDownloadResource = async (resourceUrl, assetId, format) => {50 try {51 setLoading(true);52 const response = await fetch(resourceUrl, {});5354 if (response.status >= 200 && response.status < 300) {55 const blob = await response.blob();5657 const fileUrl = URL.createObjectURL(blob);5859 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 }6768 throw await response.json();69 } catch (error) {70 // TODO: Show error message to user71 console.error(error);72 } finally {73 setLoading(false);74 }75 };7677 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 });8384 const data = await response.json();8586 if (response.status >= 200 && response.status < 300) {87 return refresh();88 }8990 throw data;91 } catch (error) {92 // TODO: Show error message to user93 console.error(error);94 } finally {95 setLoading(false);96 }97 };9899 return (<div>100 {resources.length} Resources Uploaded101 </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 Home9 </a>10 </Link>1112 <Link href="/upload">13 <a>14 Upload Photo/Video15 </a>16 </Link>17 </div>18 </nav>19 {resources.length} Resources Uploaded20 <div className="resources-wrapper">21 {resources.map((resource, index) => {22 const isVideo = resource.resource_type === "video";2324 let resourceUrl = resource.secure_url;2526 if (isVideo) {27 resourceUrl = resource.secure_url.replace(".mp4", ".gif");28 }2930 return (31 <div className="resource-wrapper" key={index}>32 <div className="resource">33 <Image34 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 <button44 disabled={loading}45 onClick={() => {46 handleDownloadResource(47 resourceUrl,48 resource.asset_id,49 isVideo ? "gif" : resource.format50 );51 }}52 >53 Download54 </button>55 <button56 disabled={loading}57 onClick={() => {58 handleDeleteResource(59 resource.public_id,60 isVideo ? "video" : "image"61 );62 }}63 >64 Delete65 </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.