DEV Community

Cover image for 3 ways to upload files using NodeJS and Axios
Saurabh Misra
Saurabh Misra

Posted on • Originally published at saurabhmisra.dev

3 ways to upload files using NodeJS and Axios

Introduction

Usually when we think about file uploads, the first thought that often comes to mind is to have a <form/> tag with the enctype="multipart/form-data" attribute set on it on the client-side with a file input to upload files.

But what if there were no forms or file inputs involved?

What if I want to upload files from the server-side to some other third-party server. For example, uploading files from your server to Amazon S3. Or maybe you are building an app or an API that is supposed to receive files. In such cases, how would we configure the API request to carry file data in the request payload?

Also, are there any other ways we can send file data in an HTTP request apart from multipart/form-data?

Yes there are and that is exactly what we're going to touch upon in this article.

We're going to learn how to send files in an HTTP request in binary format, as multipart form-data and as a base64-encoded string from the server-side and also learn how to receive and save these files in the filesystem.

For our tutorial, we're going to use NodeJS, ExpressJS and Axios.

The main aim of this tutorial is to make you aware of the various approaches for uploading files that you have at your disposal. Then you can go ahead and implement the one that suits your requirements the best using a language, library or framework of your choice.

Tutorial Project Setup

The starter files for this tutorial are in this github repo.

Once you clone or download the files, cd into the folder and run npm install followed by npm start. You should have a local server running at http://localhost:3000.

There are only two script files in the project folder: app.js and utils.js.

app.js at this point only contains imports, variable declarations, and server initialization logic. We'll go over and use these imported packages and variables as we move forward.

// imports
const express = require( 'express' ),
  axios = require( 'axios' ).default,
  multer = require( "multer" ),
  fsProm = require( "fs/promises" ),
  fs = require( "fs" ),
  FormData = require( "form-data" ),
  { getFilename } = require( "./utils" );

// global variables, constants and defaults
const PORT = 3000,
  FILE_SOURCE_DIR = "./images",
  FILE_UPLOADS_DIR = "./uploads",
  MAX_UPLOAD_SIZE = 2 * 1024 * 1024; // 2MB in bytes
  MAX_UPLOAD_SIZE_IN_MB = `${ MAX_UPLOAD_SIZE / 1024 / 1024 }MB`;
axios.defaults.baseURL = `http://localhost:${ PORT }`;
const app = express();

// YOUR CODE GOES HERE

app.listen( PORT, () => console.log( `πŸ‘‚API Server listening on port ${PORT}` ));
Enter fullscreen mode Exit fullscreen mode

utils.js houses some basic helper functions. The getFileExtFromMimeType() function performs some simple mapping between MIME types and file extensions. The other function getFilename() accepts a MIME type as an input and creates a unique file name for saving our uploads.

You'll also notice two folders named images and uploads. The images folder contains some sample images we can use for testing and the uploads folder will serve as the destination for the uploaded files.

Approach 1: Binary Upload

This is possibly the simplest approach for uploading files.

All we do here is populate the request payload with the file data in binary format and set the Content-Type HTTP header as the MIME type of the file. That's it!

Let's see this in action.

We'll create two API routes in the project, one for sending the file in binary format and the other for receiving this binary payload and saving it as a file in the filesystem.

GET /send-binary
POST /receive-binary
Enter fullscreen mode Exit fullscreen mode

In real-world scenarios, these two API services will mostly reside on different servers but for convenience in our tutorial project, we are going to implement them on the same server.

Also, since our focus in this tutorial is not on API design, I have designed /send-binary as a GET request instead of a POST request for convenience so that I can send the request directly from the browser.

Copy-paste this function definition for the /send-binary route into app.js.

app.get( "/send-binary", async ( req, res ) => {

  // Specify which image to read from the file system 
  // and the value for the Content-Type header for the upload.
  const filepath = `${ FILE_SOURCE_DIR }/valid.jpg`,
  headers = { "Content-Type": "image/jpeg" };

  let file = null;
  try {

    // Read the binary file data into a "Buffer" object.
    file = await fsProm.readFile( filepath );    

  } catch ( error ) {

    // respond with a 500 Internal Server Error if something goes wrong
    return res.status( 500 ).json({ 
      status: "error", 
      message: "Error while reading file", 
      error 
    });

  }

  try {

    // upload the binary data in the request payload
    await axios.post( "/receive-binary", file, { headers });    

  } catch (err) {

    // handle file size and file type validation error response
    if( err.response && ( err.response.status == 413 || err.response.status == 400 )) {
      return res.status( 400 ).json({ 
        status: "error", 
        message: "Invalid file. Please upload a JPEG or PNG file less than " + MAX_UPLOAD_SIZE_IN_MB + " in size."
      });
    } 

    // handle any generic error
    else {
      return res.status( 500 ).json({ 
        status: "error", 
        message: "Error while uploading file", 
        error: err 
      });
    }

  }

  return res.json({ status: "success", message: "File sent successfully." });

});
Enter fullscreen mode Exit fullscreen mode

The only main steps here are reading the file from the filesystem in a Buffer instance and then sending it in the request payload using axios. Everything else like the try-catch is just best practice.

Buffer is basically NodeJS's way of referencing binary data which is currently stored in memory or RAM.

Now let's define the route handling logic for the /receive-binary route as well. Paste the below code into app.js.

// Configure middleware to parse raw request payloads.
const parseRawReqBody = express.raw({ 
  limit: MAX_UPLOAD_SIZE, 
  type: [ "image/jpeg", "image/png" ] 
});

app.post( "/receive-binary", parseRawReqBody, async ( req, res ) => {

  // validate if file type is valid. If not, return 400 Bad Request.
  if( req.headers[ "content-type" ] !== "image/jpeg" 
    && req.headers[ "content-type" ] !== "image/png" 
    ) {
    return res.status( 400 ).json({ 
      status: "error", 
      message: "Invalid file. Please upload a JPEG or PNG file only."
    });
  }

  // Set the path where the file should be saved.
  const filename = getFilename( req.header( "Content-Type" ) ),
    filepath = FILE_UPLOADS_DIR + '/' + filename;

  try {

    // write the binary data from the request body into a file.
    await fsProm.writeFile( filepath, req.body );

  } catch (error) {

    // respond with a 500 Internal Server Error if something goes wrong
    return res.status( 500 ).json({ 
      status: "error", 
      message: "Error while writing the binary file to disk.", 
      error 
    });

  }

  return res.json({ status: "success", message: "File received and saved successfully." });

});
Enter fullscreen mode Exit fullscreen mode

Again the only main steps here are writing the binary data into a file using the writeFile() function from the fs/promises module.

Let's test this out. If you haven't already, spin up the server using npm start and visit http://localhost:3000/send-binary in the browser. You should see a successful API response. Check your uploads directory where the file should have been saved.

Let's change the name of the file in /send-binary to "too_large.jpg". Again hit the same URL as before. This time you'll see an error response which means that our file size validation does work.

Let's quickly make sure that our file format validation works as well or not. Change the name of the file to "invalid.pdf" and the Content-Type to "application/pdf". Hit the same URL. You'll again see the same error response.

You can also test uploading a PNG image by changing the file name in /send-binary to "heart.png" and changing the Content-Type to "image/png".

And there you have it! A simple way to send and save files across HTTP in binary format.

The downside of this approach is that the request body will only contain file data and nothing else. So sending any metadata in the request body is not possible. But in case you do need to send a few simple values as metadata, then that can be achieved using either query-string parameters or custom HTTP headers.

Approach #2: Multipart Uploads

So you might already be familiar with the mulipart/form-data value of the enctype attribute that is specified on a <form> tag. Multipart means that the request contains various parts separated by a pre-defined boundary. These parts can be of different types such as simple key-value pairs, plain text, file data, etc. Here is a great example of how a multipart HTTP request is formatted.

Let's implement this approach now. Just like the binary approach, we'll implement two routes here as well:

GET /send-multipart
POST /receive-multipart
Enter fullscreen mode Exit fullscreen mode

Let's define the route handler for /send-multipart first. /send-multipart will use a FormData instance to construct a multipart request payload. We'll read the file data and append it to the FormData instance along with any other required metadata. We'll then use axios to send this info in a POST request to /receive-multipart. Paste the below code into app.js.

app.get( "/send-multipart", async ( req, res ) => {

  const form = new FormData();

  // read the file and append into the FormData instance.
  const filepath = `${ FILE_SOURCE_DIR }/valid.jpg`; 
  form.append( "file", fs.createReadStream( filepath ) );

  // append any other metadata
  form.append( "firstname", "Saurabh" );

  try {

    // Send the file using axios.
    // Since we're using the FormData instance, axios will automatically set 
    // the Content-Type header as 'mulitpart/form-data'.
    await axios.post( "/receive-multipart", form );

  } catch ( error ) {

    // handle file size and file type validation error response
    if( error.response && error.response.data ) {
      return res.status( error.response.status ).json( error.response.data );
    }

    // handle any generic error
    else {
      return res.status( 500 ).json({ 
        status: "error", 
        message: "Something went wrong while uploading the image.", 
        detail: error 
      });
    }

  }

  res.json({ status: "success", message: "File sent successfully." }); 
});
Enter fullscreen mode Exit fullscreen mode

To receive and parse multipart requests, we'll use an NPM package called multer which exposes a middleware that parses multipart/form-data requests. multer is already installed in our tutorial project and also imported in our app.js file so let's go ahead and configure it. Paste the below code into app.js.

// configure multer to save images to disk.
const fileStorageEngine = multer.diskStorage({

  // configure the destination directory 
  destination: ( req, file, callback ) => {
    callback( null, FILE_UPLOADS_DIR );
  },

  // configure the destination filename
  filename: ( req, file, callback ) => {
    callback( null, getFilename( file.mimetype ) );
  }

});

// configure multer to handle single file uploads
const uploadSingleFile = multer({

  // use the configured file storage option
  storage: fileStorageEngine,

  // skip any files that do not meet the validation criteria
  fileFilter: ( req, file, callback ) => {

    if ( file.mimetype !== "image/png" && file.mimetype !== "image/jpeg" ) {

      // Store a flag to denote that this file is invalid.
      // Unlike `res.locals`, `req.locals` is not really 
      // a standard express object but we use it here
      // for convenience to pass data to the route handler.
      req.locals = { invalidFileFormat: true };

      // reject this file
      callback( null, false );
    }

    // accept this file
    callback( null, true );

  },

  // configure a max limit on the uploaded file size
  limits: { fileSize: MAX_UPLOAD_SIZE }

}).single( "file" );
Enter fullscreen mode Exit fullscreen mode

Let's go ahead and implement the route handling functionality for /receive-multipart. /receive-multipart will forward the request to multer by calling uploadSingleFile() which will parse and upload the file. The route handler will then only validate whether everything went smoothly or not and respond accordingly. Paste the below code into app.js.

app.post( "/receive-multipart", ( req, res ) => {

  // `uploadSingleFile` is a middleware but we use it here 
  // inside the route handler because we want to handle errors.
  uploadSingleFile( req, res, err => {

    // if uploaded file size is too large or if its format is invalid
    // then respond with a 400 Bad Request HTTP status code.
    if( err instanceof multer.MulterError 
      || ( req.locals && req.locals.invalidFileFormat )
      ) {
        return res.status( 400 ).json({ 
          status: "error", 
          message: `Invalid file format. Please upload a JPEG or PNG image not greater than ${MAX_UPLOAD_SIZE_IN_MB} in size.` 
      });
    }

    // handle any other generic error
    else if ( err ) {
      return res.status( 500 ).json({ 
        status: "error", 
        message: "Something went wrong while uploading the image.", 
        detail: err 
      });
    }

    res.json({ status: "success" });

  });
});
Enter fullscreen mode Exit fullscreen mode

Let's run this now and visit the URL http://localhost:3000/send-multipart. You should see a successful response and the file should have been uploaded to the uploads directory.

Now let's test our validations. Change the filepath variable to use the "too_large.jpg" file and visit the same URL again. You should see an error response.

I'll leave it up to you to test this using the PDF and PNG file.

This approach proves quite useful when you need to send metadata along with file data or multiple files in a single HTTP request.

Approach #3: Base64 uploads

Base-64 encoding is used to convert binary data into an ASCII string representation. A base64-encoded string can be easily transferred in an HTTP request as it is or as a part of a JSON.

Let's implement this approach now. Just like the binary approach, we'll implement two routes here as well.

GET /send-base64
POST /receive-base64
Enter fullscreen mode Exit fullscreen mode

Let's implement the /send-base64 route first. /send-base64 will read the file from the filesystem, convert the entire file data into a base64-encoded string, include this string in a JSON and send this JSON in a POST request using axios to /receive-base64. Paste the below code into app.js.

app.get( "/send-base64", async ( req, res ) => {

  // Specify which image to read from the file system 
  // and the value for the Content-Type header for the upload.
  const filepath = `${ FILE_SOURCE_DIR }/valid.jpg`,
    mimetype = "image/jpeg";

  let file = null;
  try {

    // read the file as a base64-encoded string
    file = await fsProm.readFile( filepath, "base64" );    

  } catch ( err ) {

    // respond with a 500 Internal Server Error if something goes wrong
    return res.status( 500 ).json({ 
      status: "error", 
      message: "Error while reading file", 
      error: err 
    });

  }

  try {

    // Upload the base64 string as JSON with other metadata.
    // `Content-Type` will automatically be set to "application/json".
    const data = { mimetype, file };
    await axios.post( "/receive-base64", data );

  } catch ( err ) {

    // handle file size
    if( err.response && ( err.response.status == 413 || err.response.status == 400 )) {
      return res.status( 400 ).json({ 
        status: "error", 
        message: "Invalid file. Please upload a JPEG or PNG file less than " + MAX_UPLOAD_SIZE_IN_MB + " in size."
      });
    }

    // handle any other generic error
    else {
      return res.status( 500 ).json({ 
        status: "error", 
        message: "Error while uploading file", 
        error: err 
      });
    }

  }

  return res.json({ status: "success", message: "File uploaded and saved successfully." });
});
Enter fullscreen mode Exit fullscreen mode

If you notice, we're using the same readFile() function from the fs/promises module that we used in the binary approach but this time we specify "base64" as the second argument as the encoding type.

Now let's implement the /receive-base64 route. /receive-base64 will receive the JSON payload, validate it, read the base64-encoded string, convert it into binary and will save the file in the filesystem. Paste the below code into app.js.

// configure middleware to parse JSON payloads in the request body.
const parseJSONReqBody = express.json({ limit: MAX_UPLOAD_SIZE });

app.post( "/receive-base64", parseJSONReqBody, async ( req, res ) => {

  // validate if file type is valid. If not, return 400 Bad Request.
  if( req.body.mimetype !== "image/jpeg" && req.body.mimetype !== "image/png" ) {
    return res.status( 400 ).json({ 
      status: "error", 
      message: "Invalid file. Please upload a JPEG or PNG file only."
    });
  }

  // convert the base64-encoded string into binary
  const binaryData = Buffer.from( req.body.file, 'base64' );

  // Set the path where the file should be saved.
  const filename = getFilename( req.body.mimetype ),
    filepath = FILE_UPLOADS_DIR + '/' + filename;

  try {

    // write the binary data into a file.
    await fsProm.writeFile( filepath, binaryData );

  } catch ( err ) {

    // respond with a 500 Internal Server Error if something goes wrong
    return res.status( 500 ).json({ 
      status: "error", 
      message: "Error while writing the file to disk.", 
      error: err 
    });

  }

  return res.json({ status: "success", message: "File saved successfully." });

});
Enter fullscreen mode Exit fullscreen mode

The only main steps here are using Buffer.from() to convert the base64-encoded string into binary data and then using writeFile() to write the binary data into a file the same way we did with binary uploads.

Let's run this now and visit the URL http://localhost:3000/send-base64. You should see a successful response and the file should have been uploaded to the uploads directory.

Now let's test our validations. Change the filepath variable to use the "too_large.jpg" file and also change the mimetype variable accordingly and visit the same URL again. You should see an error response.

I'll leave it up to you to test this using the PDF and PNG file.

The pros of this approach is that you can design a file upload API service to accept JSON payloads and share a consistent and uniform interface with the rest of the API services. But keep in mind that this is not a hard-and-fast rule and it is perfectly fine for API services to accept file upload inputs as either multipart/form-data or binary.

The disadvantage of this approach is that base64-encoding increases the payload size by ~33%. For large files, the payload can end up weighing a lot.

Summary

You don't have to rely solely on multipart/form-data payloads to upload files.

We just learnt three ways to send and receive files using NodeJS, ExpressJS and Axios.

  • Direct Binary Uploads
  • Multipart Form-Data Uploads
  • Base64-encoded Uploads

It is helpful to keep these approaches in your arsenal and choose the one that best meets your needs when you need to design one for your projects.

Top comments (0)