Building Serverless Upload For Images

Maya Shavin

Static sites (or Jamstack sites) are great in many ways, from performance to scalability. Instead of creating and maintaining a massive monolith web server, we now can easily split it to directly dealing with microservices APIs from the client-side only. While it is more straightforward working with APIs, there is one challenge - security. Since there is no back-end support for front-end needed, everything, including sensitive information such as authentication credentials, will be exposed to the HTTPS request for a specific API call, either on the request body or in the code itself. For example, using Cloudinary signed upload service for Node.js. The authentication for a signed (secured) upload is a secure protocol created based on user’s API key, API secret key, and cloud name. And you're not supposed to expose these on the client-side.

To solve that, we use a serverless function.

What is a serverless function?

A serverless function is a method that we can trigger using an HTTPS request and does not tie specifically to any web servers. It is hosted and managed by any cloud computing companies (hosting platforms) such as Digital Ocean, Netlify, Vercel, Google Cloud, AWS, etc...

Depending on the hosting platform you are using, you can write a server-function in Node.js, Golang, Java, and so on.

This tutorial will create a secured upload application with a serverless function using Cloudinary, Vercel, and Nuxtjs.

Try it yourself

Setting up

The first step is to make sure Vercel CLI is installed. Vercel CLI allows us to develop and test our serverless functions locally.

1npm i -g vercel #or yarn global add vercel

Next, let's put together a basic Nuxt application scaffold by executing the following command on your terminal:

1#Using npm (v6.1 onwards)
2npm init nuxt-app cloudinary-upload-app
3
4
5
6#Or using YARN
7yarn create nuxt-app cloudinary-upload-app
8
9
10
11#Or using npx
12npx create-nuxt-app cloudinary-upload-app

And select the configuration options accordingly. You can choose anything to suit your needs, but I recommend the following for our starter application:

  • UI framework: Tailwind CSS - we use TailwindCSS to provide a basic beautiful CSS setup without extra configuration.
  • Axios - we use this module to send API requests to the serverless function.
  • Linting tools: Prettier, Lint staged files.
  • Rendering mode: Universal (SSR/SSG) - we want Nuxt to act as a Static site generator for our application during development. This mode is required to turn on the static deployment static.
  • Deployment target: Static - this is important as we want to deploy our application as static site.

Once it's ready, we can navigate to the project directory and have it connected to Vercel using:

1cd cloudinary-upload-app && vercel

and set up your project linking accordingly. See Vercel's project settings for more information.

⚠️ You may need to login using vercel login to connect to your Vercel account.

Then run the following to have the app up and running:

1vercel dev

You can see how the app looks on the browser by holding CMD (Mac) or Ctrl and clicking on the port link displayed in the terminal:

Now we can start to create our upload component.

Upload component

In the app directory, create a new file Upload.vue under components directory with the following skeleton code:

This is our Upload component.

Next we will create the component skeleton by adding the following code:

1<template>
2 <div class="p-4 w-full">
3 <h1 class="text-xl font-semibold text-blue-700 text-center">
4 Upload with Cloudinary Demo
5 </h1>
6 <div class="my-4 mx-auto text-center">
7<!--This is our upload component-->
8<upload />
9</div>
10</div>
11</template>

Receive user input

To allow user to select a local file for uploading, we use input element with the following attributes:

  • type="file" - indicate this input field is meant to let users choose a file from their device storage.
  • accept=".jpeg,.jpg,.png,image/png,image/png,.svg,.gif" - indicate the target file type the input element should accept. By setting it to accept only image types, we make sure users will only select the right file format for our component to handle.
  • A bit of CSS styling by assigning Tailwind class names to class

In template section of Upload.vue, our code looks like:

1<input
2type="file"
3accept=".jpeg,.jpg,.png,image/jpeg,image/png,.svg,.gif"
4class="p-3 border cursor-pointer"
5aria-label="upload image button"
6/>

The output in browser becomes:

Now we need to declare a method selectFile in methods fields of the component's JavaScript logic located in <script> section:

1export default {
2methods: {
3selectFile(e){}
4}
5}

Then bind that method as a listener to change event in the input field

1<input
2type="file"
3accept=".jpeg,.jpg,.png,image/jpeg,image/png,.svg,.gif"
4class="p-3 border cursor-pointer"
5@change="selectFile"
6/>

Once a user clicks on Choose File and selects a file, selectFile will be triggered with e representing the event change's data as a parameter.

Handling the user input

We can get the list of selected files by accessing e.target.files, which contains File objects. Since we allow users to upload a single file, we only need to care about the first file on the list - files[0]

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

To upload using HTTPs, we first need to read and convert the received file's data to base64 format. For that, we use the built-in FileReader of the File API for the web and perform the following:

  • Load the file data using FileReader
  • Translate its data as base64 using FileReader.readAsDataURL method

Since these processes are asynchronous, we will use Promise API to wrap around them:

1const readData = (f) =>
2new Promise((resolve) => {
3const reader = new FileReader()
4reader.onloadend = () => resolve(reader.result)
5reader.readAsDataURL(f)
6})

And use await/async to make sure we get the output data before moving on to the next step.

1const data = await readData(file)

To this point, we get our data ready to be sent and upload. And our implementation code for selectFile() becomes:

1async selectFile(e) {
2const file = e.target.files[0]
3
4/* Make sure file exists */
5if (!file) return;
6
7const readData = (f) =>
8new Promise((resolve) => {
9const reader = new FileReader()
10reader.onloadend = () => resolve(reader.result)
11reader.readAsDataURL(f)
12})
13const data = await readData(file)
14}

So far, so good? The next step is to build a serverless function for uploading.

Add a serverless upload function

Vercel provides excellent support for serverless functions written in different backend languages. In our demo app, we use Node.js and Cloudinary Node.js SDK to write our upload serverless function.

We add the api folder to the root of the project. By default, Vercel will detect the serverless functions based on this directory and deploy them accordingly.

Let's add an upload.js file to the api folder, with the following content:

1module.exports = async (req, res) => {
2/* logic implementation here */
3}

Great. Now we need to install Cloudinary Node.js SDK as project dependency and start using it:

1npm i cloudinary #OR yarn add cloudinary

Setting up Cloudinary account

To have the Cloudinary upload service working, we need to config it with cloudName, api_key and api_Secret:

1const sdk = require('cloudinary').v2
2
3
4
5sdk.config({
6cloud_name: 'your-cloud-name',
7api_key: 'your-api-key',
8api_secret: 'your-api-secret',
9})

You can find these keys at the your Cloudinary Dashboard page or Settings page:

Then we can execute an upload under the registered Cloudinary account based on the req.body.file received from the HTTPs request:

1module.exports = async (req, res) => {
2const body = req.body
3const uploader = sdk.uploader
4
5
6
7const resource = await uploader.upload(body.file)
8
9return res.json(resource)
10}

Our complete code for upload.js will be:

1const sdk = require('cloudinary').v2
2
3
4
5sdk.config({
6cloud_name: 'your-cloud-name',
7api_key: 'your-api-key',
8api_secret: 'your-api-secret',
9})
10
11
12
13module.exports = async (req, res) => {
14const body = req.body
15const uploader = sdk.uploader
16
17
18
19const resource = await uploader.upload(body.file)
20
21return res.json(resource)
22}

That's it. Let's go back to our Upload component and connect its selectFile to this serverless function, shall we?

Calling the upload server-less function

Inside selectFile() implementation of Upload.vue, we call the serverless function through a HTTPS POST request using $axios

1const resource = await this.$axios.$post('/api/upload', {
2file: data
3})

And if the upload is completed successfully, we will receive a JSON object containing all relevant data for the uploaded image. Otherwise, it will return a JSON object containing error with msg field indicating the error.

Making the UI informative

To make our UI be more informative during the uploading process, let's add a loading , image and error variables to data section of the component.

1data() {
2return {
3loading: false,
4error: null,
5image: null, //contain the uploaded image
6}
7},

And assign values them accordingly during selectFile() execution:

1async selectFile(e) {
2const file = e.target.files[0]
3
4/* Make sure file exists */
5if (!file) return;
6
7
8
9this.loading = true //Start loading
10
11const readData = (f) =>
12new Promise((resolve) => {
13const reader = new FileReader()
14reader.onloadend = () => resolve(reader.result)
15reader.readAsDataURL(f)
16})
17const data = await readData(file)
18const resource = await this.$axios.$post('/api/upload', {
19file: data
20})
21
22this.loading = false //End loading
23
24if (resource.error) {
25this.error = resource.error
26return
27}
28
29this.image = resource
30}

Then add the HTML code to handle the following:

  • Disable the input field when an upload is going on.
  • Display an "Uploading..." message when an upload is going on
  • Display an error message when something is wrong.
  • Display the uploaded image otherwise.
1<div class="upload-comp">
2<input
3type="file"
4accept=".jpeg,.jpg,.png,image/jpeg,image/png,.svg,.gif"
5class="p-3 border cursor-pointer"
6@change="selectFile"
7:disabled="loading"
8/>
9<div class="m-5 text-lg font-thin italic" v-show="loading">
10Uploading...
11</div>
12<div v-if="error && !loading" class="m-5">{{ error.msg }}</div>
13<img
14v-if="!loading && image"
15:src="image.secure_url"
16class="m-5"
17>
18</div>

Now our complete code for Upload.vue is:

  • template section:
1<template>
2<div class="upload-comp">
3<input
4type="file"
5accept=".jpeg,.jpg,.png,image/jpeg,image/png,.svg,.gif"
6class="p-3 border cursor-pointer"
7@change="selectFile"
8:disabled="loading"
9/>
10<div class="m-5 text-lg font-thin italic" v-show="loading">
11Uploading...
12</div>
13<div v-if="error && !loading" class="m-5">{{ error.msg }}</div>
14<img
15v-if="!loading && image"
16:src="image.secure_url"
17class="m-5"
18>
19</div>
20</template>
  • script section
1<script>
2export default {
3data() {
4return {
5loading: false,
6error: null,
7image: null,
8}
9},
10methods: {
11async selectFile(e) {
12const file = e.target.files[0]
13
14/* Make sure file exists */
15if (!file) return;
16
17
18
19this.loading = true
20
21const readData = (f) =>
22new Promise((resolve) => {
23const reader = new FileReader()
24reader.onloadend = () => resolve(reader.result)
25reader.readAsDataURL(f)
26})
27const data = await readData(file)
28const resource = await this.$axios.$post('/api/upload', {
29file: data
30})
31
32this.loading = false
33
34
35
36if (resource.error) {
37this.error = resource.error
38return
39}
40
41this.image = resource
42}
43}
44}
45</script>

And finally, you can simply run the following to have your application deploy to Vercel on production.

1vercel --prod

Summary

At this point you have a functional application from front-end to back-end to securely upload an asset with the least set up required. The next step is to play around with the upload configurations from Cloudinary to pre-generate transformations, or getting smart color detection, and so on. And then using the Cloudinary module for Nuxt to dynamically display the uploaded asset with optimization on the client-side.

Maya Shavin

Senior Software Engineer @ Microsoft

Maya is Senior Software Engineer @Microsoft, founder of VueJS Israel, core maintainer of StorefrontUI, Nuxt Ambassador, MDE, and a writer on JavaScript good practices. She has been developing web apps with Angular, VueJS and recently ReactJS. She loves to learn and experiment with new frameworks while believing that a strong Vanilla JavaScript knowledge is necessary for being a good web developer. When not coding, she enjoys traveling, reading manga, and sketching