Skip to content

RESOURCES / BLOG

Virtual event badge using Cloudinary and Next.js

The Jamstack Conference 2021, is right around the corner. Those who wish to attend the virtual conference can generate an amazing-looking badge on the website. I thought that this was super cool and wanted to make an attempt at it using Cloudinary’s chained transformations and Next.js.

The final project can be viewed on Codesandbox.

You can find the full source code on my Github repository.

The deployed application can also be tested Here

For this tutorial, working knowledge of Javascript, HTML, and CSS is required. You’ll also need to be familiar with the basics of React.js and Node.js. With that, you should be able to follow along smoothly. Before anything, ensure that you have Node.js installed in your development environment. The official documentation has an amazing guide on how to get up and running. You can also check out Node version manager, which could be handy for upgrading to new versions and switching between different versions. We’ll also need API credentials from cloudinary. These will allow us to identify ourselves and communicate with the API.

If you’re not familiar with Cloudinary, it’s a service that provides developers with a number of solutions that allow them to upload/store media, transform it and/or optimize it. It’s super convenient, easy to use, and cheap as well, compared to other solutions. The best thing is that you can get started immediately with a free developer account and scale up as your needs grow. For development and testing purposes, it’s very hard to exceed the free usage limits. You should, however, be careful with how you use the API.

Head over to Cloudinary, create an account if you do not have one, and sign in to your account. Once inside, navigate to the dashboard page. Here you will find your API credentials. We’re not going to use them now, but we’ll do so in the next section. Take note of the Cloud name, API Key, and API Secret.

Cloudinary Dashboard

Here’s what we’re trying to implement. We have a pre-built tag frame. This is just a transparent image of the actual tag. We’ll then add two image overlays on the pre-built frame and finally add the user’s name as an overlay as well. The overlays will be applied using Cloudinary’s chained transformations. We could upload the frame + two images on every operation but this would just take up a lot of space on cloudinary. Instead, when the application is first to run, it will check if the tag frame exists on cloudinary and if not, will upload it. This way, we only have to upload the two images without the frame image.

Next.js is our framework of choice for this tutorial. Let’s create a new project. It’s easy to scaffold a project using the CLI app create-next-app. Open your terminal/command line in your desired project folder and run the following command.


npx create-next-app virtual-event-tag

This creates a new project named virtual-event-tag and installs the dependencies. You can explore more installation options in the Next.js documentation. You can then switch the directory into the newly created project.


cd virtual-event-tag

Open the project in your favorite code editor and proceed to the next section.

Let’s first install the Cloudinary SDK for Node.js. Run the following command in your terminal.


npm install cloudinary

Create a new file called .env.local at the root of your project. We’ll be defining our API keys as environment variables in this file. Next.js has built-in support for environment variables. Read more about it in the docs. Paste the following inside .env.local.


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 that we got from the cloudinary-api-credentials section.

Create a new folder at the root of your project and name it lib. This folder will hold the shared code. Inside this folder, create a new file called constants.js. This fill will hold a few constant values. Paste the following inside.


// lib/constants.js

  

/**

* Name of the folder where our images will be stored

*/

export  const  CLOUDINARY_FOLDER_NAME  =  "virtual-event-tags/";

  

/**

* Name of the badge image/frame

*/

export  const  BADGE_FRAME_NAME  =  "badge-bg";

Code language: JavaScript (javascript)

CLOUDINARY_FOLDER_NAME is the name of the folder where we’ll be storing all our resources on cloudinary. BADGE_FRAME_NAME is the name of the virtual tag’s frame background/image.

Inside the lib folder, create a new file called cloudinary.js. This is where all of our cloudinary code will reside. Paste the following inside.


// lib/cloudinary.js

  

// Import the v2 api and rename it to cloudinary

import  {  v2  as  cloudinary,  TransformationOptions  }  from  "cloudinary";

import  {  CLOUDINARY_FOLDER_NAME  }  from  "./constants";

  

// 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,

});

  

/**

* Gets a resource from cloudinary using its public id

*

* @param  {string}  publicId The public id of the image

*/

export  const  handleGetCloudinaryResource  =  (publicId)  =>  {

return  cloudinary.api.resource(`${CLOUDINARY_FOLDER_NAME}${publicId}`,  {

resource_type:  "image",

type:  "upload",

});

};

  

/**

* Applies chained transformations to an image and returns the url

*

* @param  {string}  publicId The public id of the image

* @param  {TransformationOptions}  transformation The transformation options to run on the image

*/

export  const  handleGetTransformImageUrl  =  (publicId,  transformation)  =>  {

return  cloudinary.url(publicId,  {  transformation  });

};

  

/**

* Uploads an image to cloudinary and returns the upload result

*

* @param  {{path: string; transformation?:TransformationOptions,publicId?: string }}  resource

*/

export  const  handleCloudinaryUpload  =  (resource)  =>  {

return  cloudinary.uploader.upload(resource.path,  {

// Folder to store the image in

folder:  CLOUDINARY_FOLDER_NAME,

// Public id of the image.

public_id:  resource.publicId,

// Type of resource

resource_type:  "auto",

// Transformation to apply to the video

transformation:  resource.transformation,

});

};

  

Code language: JavaScript (javascript)

I have commented on most of the important parts. Let’s just go through it briefly. We first import the v2 API from the SDK and then rename it cloudinary for readability and consistency. We also import CLOUDINARY_FOLDER_NAME from lib/constants.js. Next, we initialize the SDK by calling the config method on the SDK and passing cloud_name, api_key, and api_secret. Here we use the environment variables that we defined earlier.

handleGetCloudinaryResource takes in a public ID, then uses the cloudinary Admin API to get the resource with that public id.

handleGetTransformImageUrl takes in a public ID, and an array of transformations to run on the resource with that public ID. It then chains the transformations onto the resource and returns the resulting url. Read more about this and chained transformations here.

handleCloudinaryUpload takes in an object containing path,optional transformation and optional publicId. It then calls the uploader.upload method on the SDK to upload a resource. Read about upload options here.

Let’s move on.

We’re going to be using Next.js API routes to get the images and user’s names from the frontend. API routes in Next.js are really handy and eliminate the need for a separate backend application. Read about them in the official documentation.

We’re also going to be using a package known as formidable to parse the incoming form data. Let’s install that. Run the following in your terminal.


npm install formidable

We’re now ready. Create a folder called images under the pages/api folder. Proceed to create two files inside pages/api/images, one named index.js and the other [id].js. The former will handle calls made to the api/images endpoint and the latter will handle calls to the api/images/:id endpoint. This is all standard and covered in the docs.

Paste the following inside pages/api/images/index.js.


// pages/api/images/index.js

  

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

import  {  IncomingForm  }  from  "formidable";

import  {  NextApiRequest,  NextApiResponse  }  from  "next";

import  {

handleCloudinaryUpload,

handleGetTransformImageUrl,

}  from  "../../../lib/cloudinary";

import  {

BADGE_FRAME_NAME,

CLOUDINARY_FOLDER_NAME,

}  from  "../../../lib/constants";

  

// Custom config for our API route

export  const  config  =  {

api:  {

bodyParser:  false,

},

};

  

/**

* The handler function for the API route. Takes in an incoming request and outgoing response.

*

* @param  {NextApiRequest}  req The incoming request object

* @param  {NextApiResponse}  res The outgoing response object

*/

export  default  async  function  handler(req,  res)  {

switch  (req.method)  {

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"  });

}

}

}

  

/**

* Handles the POST request to the API route.

*

* @param  {NextApiRequest}  req The incoming request object

*/

const  handlePostRequest  =  async  (req)  =>  {

// Get the form data using the parseForm function

const  data  =  await  parseForm(req);

  

// Get the name of the user from the incoming form data

const  name  =  data.fields.name;

  

// Get the how it started image file from the incoming form data

const  thenImage  =  data.files.then;

  

// Get the how it's going image file from the incoming form data

const  nowImage  =  data.files.now;

  

// Upload the how it started image to Cloudinary

const  thenImageUploadResult  =  await  handleCloudinaryUpload({

path:  thenImage.path,

});

  

// Upload the how it's going image to Cloudinary

const  nowImageUploadResult  =  await  handleCloudinaryUpload({

path:  nowImage.path,

});

  

// Use Cloudinary to overlay the two images over the badge frame using chained transformations

const  url  =  handleGetTransformImageUrl(

// The badge frame image

`${CLOUDINARY_FOLDER_NAME}${BADGE_FRAME_NAME}`,

[

{

// Then image

overlay:  thenImageUploadResult.public_id.replace(/\//g,  ":"),

width:  110,

height:  110,

crop:  "scale",

gravity:  "north_west",

x:  56,

y:  478,

},

{

// Now image

overlay:  nowImageUploadResult.public_id.replace(/\//g,  ":"),

width:  150,

height:  150,

crop:  "scale",

gravity:  "north_west",

x:  220,

y:  405,

},

{

// Name layer

overlay:  {

font_family:  "Arial",

font_size:  36,

font_weight:  "bold",

stroke:  "stroke",

letter_spacing:  2,

text:  name,

},

border:  "5px_solid_black",

color:  "white",

width:  333,

crop:  "fit",

gravity:  "north_west",

x:  50,

y:  650,

},

]

);

  

return  url;

};

  

/**

* Parses the incoming form data.

*

* @param  {NextApiRequest}  req The incoming request object

*/

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)

Let’s go over that. At the top after our imports, we export a custom config file. This file instructs Next.js not to use the default body parser for incoming requests. We’ll handle this on our own using formidable. Read more about custom config in the docs.

We then have our default handler function that will handle the incoming request. We use a switch statement so that we can allow only POST requests and return a status code 405 – Method not allowed for the rest.

parseForm takes in the incoming HTTP request and parses it to get the form data. You can read about the options used in the Formidable docs.

handlePostRequest handles the Post request that is received. It first passes the incoming request to parseForm to get the form data. We then get the user’s name and how it started/how it’s going images from the parsed form data. We proceed to upload the two images. After the upload, we use the handleGetTransformImageUrl function we created earlier to chain a few transformations to our tag frame image. We already know the public id of the tag frame image, so we pass that. Next is the transformations, this is the complicated part. We need to place the two images and the name text as overlays over the tag frame image. For this to work out perfectly we need to know exactly where to place the images. You might be wondering how I came up with the values for x,y coordinates, and the width and height. To get these values, there’s no easy way really. You just have to know where to place them. If you designed the tag frame image yourself, this is easy. You just have to note down the coordinates when designing the frame image. In our case, we used a pre-built tag frame image. You could use resources online to determine where the placeholder boxes are. One other way and this is what I used, is to use the Cloudinary editor. I signed into my cloudinary dashboard, uploaded the tag image and a sample image, then used the edit feature on the tag image to place the sample images. From that I just took the values and applied them in the code. Now as long as I use the same tag image, the overlays will always be relative to that tag’s width and height.

One thing to note is the gravity option. When placing overlays using cloudinary, the origin(0,0) is at the center of the image. There’s no problem using this but I’m more accustomed to the traditional origin where it’s placed at the top left corner of the parent. So to move the origin(0,0) to the top left corner we set the gravity to north_west. The rest of the options are straightforward. Read about them here. You can also have a read of this blog post. A few more things to note. You’ll notice that when we pass the overlay for the two images, we’re using regex to replace all / characters with :. This is important for the chained transformations to work effectively. Second, for the name overlay, we’re giving it a width and also a crop option. This is to allow the text to wrap if it’s too big.

Finally, we return the url that already has the chained transformations. Moving on, let’s create the other endpoint. This endpoint will mainly handle getting the tag frame image if it exists and uploading it if it does not exist. Paste the following code inside pages/api/images/[id].js


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

  

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

  

import  {

handleCloudinaryUpload,

handleGetCloudinaryResource,

}  from  "../../../lib/cloudinary";

  

/**

* The handler function for the API route. Takes in an incoming request and outgoing response.

*

* @param  {NextApiRequest}  req The incoming request object

* @param  {NextApiResponse}  res The outgoing response object

*/

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(error?.error?.http_code  ??  400)

.json({ message:  "Error",  error  });

}

}

  

case  "POST":  {

try  {

if  (!id)  {

throw  "id param is required";

}

  

const  result  =  await  handlePostRequest(id);

  

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

}  catch  (error)  {

return  res

.status(error?.error?.http_code  ??  400)

.json({ message:  "Error",  error  });

}

}

  

default:  {

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

}

}

}

  

/**

* Handles the GET request to the API route.

*

* @param  {string}  id The public id of the image to retrieve

* @returns

*/

const  handleGetRequest  =  (id)  =>  {

return  handleGetCloudinaryResource(id);

};

  

/**

* Handles the POST request to the API route.

*

* @param  {string}  id The public id that will be given to the image

* @returns

*/

const  handlePostRequest  =  (id)  =>  {

return  handleCloudinaryUpload({

path:  "public/images/badge-bg.png",

publicId:  id,

});

};

Code language: PHP (php)

handleGetRequest gets the resource using the id passed and returns the image to the frontend. If the request fails, the frontend will check if it’s a 404 – Not Found error and make a POST request to the same endpoint to upload the missing resource.

handlePostRequest handles the post request. It will upload the tag frame image to cloudinary so that we don’t have to upload it on every request. For this case, we’ve passed a static string pointing to a file that we’ve saved in the public/images folder. You can download this file from here. Also note that here we’re passing a public id that cloudinary will use instead of letting it assign one for us automatically.

That’s it for the backend.

We’ll have two pages for the frontend, one to customize and save the tag and another to view the customized tag and download it. Paste the following inside pages/index.js.


import  {  useRouter  }  from  "next/router";

import  Head  from  "next/head";

import  Image  from  "next/image";

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

import  {  BADGE_FRAME_NAME  }  from  "../lib/constants";

import  Link  from  "next/link";

  

export  default  function  Home()  {

const  router  =  useRouter();

  

/**

* State to hold the how it started image

* @type  {[File,Function]}

*/

const  [thenImage,  setThenImage]  =  useState(null);

  

/**

* State to hold the how it's going image

* @type  {[File,Function]}

*/

const  [nowImage,  setNowImage]  =  useState(null);

  

/**

* State to hold the name of the user

* @type  {[string,Function]}

*/

const  [name,  setName]  =  useState("");

  

/**

* Loading state

* @type  {[boolean,Function]}

*/

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

  

/**

* State to hold the badge frame url

* @type  {[string,Function]}

*/

const  [badgeFrameUrl,  setBadgeFrameUrl]  =  useState("");

  

/**

* State to hold the generated tag url

* @type  {[string,Function]}

*/

const  [tagUrl,  setTagUrl]  =  useState("");

  

const  checkBadgeFrameExists  =  useCallback(async  ()  =>  {

setLoading(true);

  

try  {

// Check if the tag url exists in local storage

const  url  =  window.localStorage.getItem("tagUrl");

  

// If it does, update the tag url state

if  (url)  {

setTagUrl(url);

}

  

// Make GET request to the /api/images endpoint to check if the badge frame exists

const  response  =  await  fetch(`/api/images/${BADGE_FRAME_NAME}`,  {

method:  "GET",

});

  

const  data  =  await  response.json();

  

// Check if the response is a failure

if  (!response.ok)  {

// If the status is 404, the badge frame doesn't exist

if  (response.status  ===  404)  {

// Make a POST request to the /api/images endpoint to create the badge frame

const  uploadResponse  =  await  fetch(

`/api/images/${BADGE_FRAME_NAME}`,

{

method:  "POST",

}

);

  

const  uploadResponseData  =  await  uploadResponse.json();

  

if  (!response.ok)  {

throw  uploadResponseData;

}

  

// Update the badge frame url state

setBadgeFrameUrl(uploadResponseData.result.secure_url);

}  else  {

throw  data;

}

}

  

// Update the badge frame url state

setBadgeFrameUrl(data.result.secure_url);

}  catch  (error)  {

// TODO: Show error message to the user

console.error(error);

}  finally  {

setLoading(false);

}

},  []);

  

useEffect(()  =>  {

checkBadgeFrameExists();

},  [checkBadgeFrameExists]);

  

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/images endpoint

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

method:  "POST",

body:  formData,

});

  

const  data  =  await  response.json();

  

if  (!response.ok)  {

throw  data;

}

  

// Save the tag url to local storage

window.localStorage.setItem("tagUrl",  data.result);

  

// Navigate to the tag page

router.push("/tag");

}  catch  (error)  {

// TODO: Show error message to the user

console.error(error);

}  finally  {

setLoading(false);

}

};

return  (

<div className="wrapper">

<Head>

<title>Create virtual event tag</title>

<meta name="description"  content="Create virtual event tag"  />

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

</Head>

  

<main>

<div className="badge-wrapper">

<Image

src="/images/badge-bg.png"

alt="Badge"

layout="fixed"

width={433}

height={909}

></Image>

<div className="then">

{thenImage  &&  (

<Image

src={URL.createObjectURL(thenImage)}

alt="Then Image"

layout="fill"

></Image>

)}

</div>

<div className="now">

{nowImage  &&  (

<Image

src={URL.createObjectURL(nowImage)}

alt="Now Image"

layout="fill"

></Image>

)}

</div>

<p className="name">{name}</p>

</div>

  

<form onSubmit={handleFormSubmit}>

<p className="heading">Customize your virtual tag</p>

<div className="input-wrapper">

<label htmlFor="name">Full Name</label>

<input

type="text"

placeholder="Enter your full name"

name="name"

id="name"

required

autoComplete="name"

disabled={loading}

onChange={(e)  =>  setName(e.target.value)}

/>

</div>

<div className="file-input-wrapper">

<div className="input-wrapper">

<label htmlFor="then">

<div className="upload-img then-img">

<Image

src="/images/btn-image-upload.svg"

alt="image-upload"

layout="fixed"

width={50}

height={50}

></Image>

</div>

<p>How it started</p>

</label>

<input

type="file"

name="then"

id="then"

required

multiple={false}

accept=".png, .jpg, .jpeg"

disabled={loading}

onChange={(e)  =>  {

const  file  =  e.target.files[0];

  

setThenImage(file);

}}

/>

</div>

<div className="input-wrapper">

<label htmlFor="now">

<div className="upload-img now-img">

<Image

src="/images/btn-image-upload.svg"

alt="image-upload"

layout="fixed"

width={50}

height={50}

></Image>

</div>

  

<p>How it&apos;s going</p>

</label>

<input

type="file"

name="now"

id="now"

required

multiple={false}

accept=".png, .jpg, .jpeg"

disabled={loading}

onChange={(e)  =>  {

const  file  =  e.target.files[0];

  

setNowImage(file);

}}

/>

</div>

</div>

<p className="instructions">

We would love to see the journey you have been through. Select two

photos, one for how it started and another for how it is going.

Square photos less than 2MB work best.

</p>

<button

type="submit"

disabled={

!badgeFrameUrl  ||  !thenImage  ||  !nowImage  ||  !name  ||  loading

}

>

Laminate Tag

</button>

{tagUrl  &&  (

<Link  href="/tag"  passHref>

<button>View Your Existing Tag</button>

</Link>

)}

</form>

</main>

<style jsx>{`

div.wrapper {

height: 100vh;

}

  

main {

display: flex;

flex-flow: row wrap;

justify-content: center;

gap: 50px;

align-items: center;

min-height: 100%;

width: 100%;

background-color: var(--background-color, #132e74);

}

  

main div.badge-wrapper {

position: relative;

}

main div.badge-wrapper div.then {

position: absolute;

width: 110px;

height: 110px;

top: 478px;

left: 56px;

}

main div.badge-wrapper div.now {

position: absolute;

width: 148px;

height: 148px;

top: 405px;

left: 220px;

}

  

main div.badge-wrapper p.name {

font-family: "Alata", sans-serif;

width: 80%;

position: absolute;

top: 600px;

left: 50px;

font-size: 2.6em;

font-weight: bold;

line-height: 1.1em;

color: #ffffff;

-webkit-text-stroke: 1px black;

text-shadow: -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000,

3px 5px 0 #000;

}

main form {

flex: 1 0 100%;

background-color: var(--primary-color, #ffee00);

max-width: 450px;

min-height: 650px;

height: 70%;

padding: 20px;

border-radius: 20px;

box-shadow: 8px 8px 0px 0px rgb(0 0 0);

display: flex;

flex-flow: column nowrap;

justify-content: center;

}

main form p.heading {

font-family: "Alata", sans-serif;

font-size: 2em;

font-weight: 900;

-webkit-text-stroke: 1px white;

}

main form > div.input-wrapper {

display: flex;

flex-flow: column nowrap;

}

main form > div.input-wrapper label {

font-weight: 800;

font-size: 1.5em;

letter-spacing: 0.5px;

margin-bottom: 5px;

font-family: "Amatic SC", cursive;

}

main form > div.input-wrapper input {

font-family: "Alata", sans-serif;

height: 50px;

padding: 10px;

border-radius: 5px;

background-color: #ffffff;

border: 2px solid #000000;

font-size: 1.2em;

font-weight: bold;

box-shadow: 5px 5px 0px 0px rgb(0 0 0);

}

  

main form > div.file-input-wrapper {

display: flex;

flex-flow: row nowrap;

margin: 40px 0 10px;

}

main form > div.file-input-wrapper div.input-wrapper {

width: 50%;

}

main form > div.file-input-wrapper div.input-wrapper label {

width: 100%;

display: flex;

flex-flow: column nowrap;

justify-content: center;

align-items: center;

font-family: "Amatic SC", cursive;

font-weight: 800;

font-size: 1.5em;

letter-spacing: 0.5px;

}

main

form

> div.file-input-wrapper

div.input-wrapper

label

div.upload-img {

position: relative;

width: 60px;

height: 60px;

background-color: #ffffff;

border-radius: 5px;

display: flex;

justify-content: center;

align-items: center;

}

main

form

> div.file-input-wrapper

div.input-wrapper

label

div.upload-img.then-img {

box-shadow: 8px 8px 0px 0px rgb(251 171 42);

}

main

form

> div.file-input-wrapper

div.input-wrapper

label

div.upload-img.now-img {

box-shadow: 8px 8px 0px 0px rgb(251 87 171);

}

main form > div.file-input-wrapper div.input-wrapper input {

height: 1px;

width: 1px;

visibility: hidden;

}

main form p.instructions {

font-family: "Alata", sans-serif;

font-size: 1em;

font-weight: 500;

text-align: center;

}

main form button {

background-color: #ffffff;

width: fit-content;

height: 50px;

padding: 10px 20px;

border-radius: 5px;

font-family: "Alata", sans-serif;

font-size: 1.5em;

font-weight: bold;

margin: 10px auto;

box-shadow: 5px 5px 0px 0px rgb(0 0 0);

}

main form button:disabled {

background-color: #cfcfcf;

}

main form button:hover:not([disabled]) {

background-color: var(--secondary-color, #3658f8);

color: #ffffff;

}

`}</style>

</div>

);

}

Code language: JavaScript (javascript)

This is a basic react component. We have a few state objects to store various states. Read about the useState hook here. We then define a memoized callback using the useCallback hook. This callback function will check the local storage for an item called tagUrl. If it exists this means the user has already customized a badge and its url was saved to the local storage. It also checks to see if a tag url image exists on cloudinary and proceeds to create one if it does not exist. We also use a useEffect hook to run the memoized checkBadgeFrameExists function. Read about the useCallback and useEffect hooks here. handleFormSubmit just posts form data to the api/images endpoint for upload. If the upload is successful, it stores the resulting url in the browser’s local storage and then navigates to the /tag page. The Local Storage API is straightforward and easy to use. I won’t go into the specifics but you can read extensively about it in the Official documentation.

For the HTML we display the tag frame on the left and a form on the right. The form has inputs for the user’s name, how it started the image, and how it’s going image. When the user selects an image, we update the correct state, either thenImage or nowImage. Then we use URL.createObjectURL to create a blob url for that image and use the url to actually display the image. Read about this here.

One thing to note about the styling. You’ll notice that we’ve used a few CSS Variables to define some colors. I won’t go too much into that. Please have a read for yourself on CSS variables here. Let’s define them now. Add the following to styles/globals.css


/* styles/global.css */

  

:root  {

--background-color:  #132e74;

--primary-color:  #ffee00;

--secondary-color:  #3658f8;

}

Code language: CSS (css)

We have also included some custom fonts from Google fonts. Add the following to the top of styles/globals.css.


/* styles/global.css */

  

@import  url("https://fonts.googleapis.com/css2?family=Alata&family=Amatic+SC:wght@400;700&display=swap");

Code language: CSS (css)

Let’s do the /tag page. Create a new file named tag.js inside pages/ folder and paste the following inside.


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

import  Link  from  "next/link";

import  Image  from  "next/image";

  

export  default  function  Tag()  {

/**

* State to hold the generated tag url

* @type  {[string,Function]}

*/

const  [tagUrl,  setTagUrl]  =  useState("");

  

/**

* Loading state

* @type  {[boolean,Function]}

*/

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

  

const  getTag  =  useCallback(()  =>  {

try  {

setLoading(true);

  

// Get the tag url from local storage

const  url  =  window.localStorage.getItem("tagUrl");

  

// If the url is not empty, update the state

if  (url)  {

setTagUrl(url);

}

}  catch  (error)  {

}  finally  {

setLoading(false);

}

},  []);

  

useEffect(()  =>  {

getTag();

},  [getTag]);

  

const  handleDownloadResource  =  async  ()  =>  {

try  {

setLoading(true);

  

const  response  =  await  fetch(tagUrl,  {});

  

if  (response.ok)  {

const  blob  =  await  response.blob();

  

const  fileUrl  =  URL.createObjectURL(blob);

  

const  a  =  document.createElement("a");

a.href  =  fileUrl;

a.download  =  `my-virtual-tag.png`;

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);

}

};

  

return  (

<div>

<main>

<div className="flex-wrapper">

{!loading  &&  tagUrl  &&  (

<div className="tag">

<div className="tag-image">

<Image

src={tagUrl}

alt="Virtual Tag"

layout="intrinsic"

width={433}

height={909}

></Image>

</div>

<div className="share-sheet">

<p>

Here is your tag. You can download it and share it with your

friends.

</p>

<button

onClick={()  =>  {

handleDownloadResource();

}}

>

Download Tag

</button>

  

<Link  href="/"  passHref>

<button>Back to home</button>

</Link>

</div>

</div>

)}

{loading  &&  (

<div className="loading">

<p>Loading...</p>

<p>Please be patient</p>

</div>

)}

{!loading  &&  !tagUrl  &&  (

<div className="no-tag">

<p>

You have not yet generated a tag or you cleared your browser

storage and we have lost it.

</p>

<Link  href="/"  passHref>

<button>Create Tag</button>

</Link>

</div>

)}

</div>

</main>

<style jsx>{`

main {

min-height: 100vh;

width: 100vw;

background-color: var(--background-color);

}

main div.flex-wrapper {

min-height: 100vh;

display: flex;

justify-content: center;

align-items: center;

}

main div.flex-wrapper div.tag {

width: 100%;

display: flex;

flex-flow: row wrap;

justify-content: center;

align-items: center;

gap: 50px;

  

display: flex;

}

main div.flex-wrapper div.tag div.tag-image {

position: relative;

height: 100vh;

max-width: 400px;

}

  

main div.flex-wrapper div.tag div.share-sheet,

main div.flex-wrapper div.no-tag,

main div.flex-wrapper div.loading {

max-width: 600px;

margin: auto;

padding: 80px;

display: flex;

flex-flow: column nowrap;

justify-content: center;

align-items: center;

background-color: var(--primary-color);

border-radius: 20px;

box-shadow: 8px 8px 0px 0px rgb(0 0 0);

}

  

main div.flex-wrapper div.tag div.share-sheet p,

main div.flex-wrapper div.no-tag p,

main div.flex-wrapper div.loading p {

font-family: "Alata", sans-serif;

font-size: 1em;

font-weight: 500;

text-align: center;

}

  

main div.flex-wrapper div.tag div.share-sheet {

max-width: 450px;

margin: 0;

}

  

main button {

background-color: #ffffff;

width: fit-content;

height: 50px;

padding: 10px 20px;

border-radius: 5px;

font-family: "Alata", sans-serif;

font-size: 1.5em;

font-weight: bold;

margin: 10px auto;

box-shadow: 5px 5px 0px 0px rgb(0 0 0);

}

main button:disabled {

background-color: #cfcfcf;

}

main button:hover:not([disabled]) {

background-color: var(--secondary-color);

color: #ffffff;

}

`}</style>

</div>

);

}

  

Code language: JavaScript (javascript)

Again, this is just a basic React.js component with a few state hooks to hold different states and a memoized callback function called getTag. The useEffect hook will run getTag when the component is first rendered. getTag will then check the local storage to get the tag url that was saved when we customized our tag in the home page.

handleDownloadResource gets the tag image as a blob then uses URL.createObjectURL to create a blob file url that we can then download to the user’s device. Nothing special going on with the HTML and styling.

Only one more thing left now. In our HTML, we’re using the Image component from Next.js. This component optimizes images that we use in our application. Read about it from the docs. As part of these optimizations, we need to notify Next.js of the domains that we’ll be getting images from. Create a file called next.config.js at the root of your project if it does not exist and paste the following code inside.


module.exports  =  {

// Any other options

images:  {

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

},

};

Code language: JavaScript (javascript)

We’ve added the res.cloudinary.com domain. Read more about these optimizations here And that’s it, we’re ready to run our application. Run the following in your terminal.


npm run dev

This builds and runs the application in development mode. Check out the docs on how to build for production. 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