tutorial // May 12, 2021

How to Create and Download a Zip File with Node.js and JavaScript

How to create and populate a zip archive in Node.js and then download it in the browser using JavaScript.

How to Create and Download a Zip File with Node.js and JavaScript

Getting started

For this tutorial, we're going to use the CheatCode Node.js Server Boilerplate as well as the CheatCode Next.js Boilerplate. Let's clone each of these now and install the dependencies we'll need for both.

Starting with the server:

Terminal

git clone https://github.com/cheatcode/nodejs-server-boilerplate.git

Next, install the server boilerplate's built-in dependencies:

Terminal

cd nodejs-server-boilerplate && npm install

After those are complete, add the jszip dependency that we'll use to generate our zip archive:

Terminal

npm install jszip

With that set, next, let's clone the Next.js boilerplate for the front-end:

Terminal

git clone https://github.com/cheatcode/nextjs-boilerplate.git

Again, let's install the dependencies:

Terminal

cd nextjs-boilerplate && npm install

And now, let's add the b64-to-blob and file-saver dependencies we'll need on the client:

Terminal

npm i b64-to-blob file-saver

Now, in separate tabs/windows in your terminal, let's start up the server and client with (both use the same command from the root of the cloned directory—nodejs-server-boilerplate or nextjs-boilerplate):

Terminal

npm run dev

Adding an endpoint where we'll retrieve our zip archive

First, let's wire up a new Express.js endpoint in the server that we can call from the client to trigger the download of our zip archive:

/api/index.js

import graphql from "./graphql/server";
import generateZipForPath from "../lib/generateZipForPath";

export default (app) => {
  graphql(app);

  app.use("/zip", async (req, res) => {
    const zip = await generateZipForPath("lib");
    res.send(zip);
  });
};

Very simple. Here, we just want a simple route that we can use as a "remote control" to trigger the download of our zip archive and return its contents to us on the client. Here, we're using the main API index.js file included in the Node.js server boilerplate (nothing more than a wrapper function to organize code—no special conventions here).

To do it, we create a new route on our Express app (passed to us via the /index.js file at the root of the boilerplate) with app.use(), passing /zip for the URL that we'll call to. Next, in the callback for the route, we call to the function we'll build next—generateZipForPath()—passing the directory on the server that we want to "zip up." In this case, we'll just use the /lib directory at the root of the server as an example.

Next, let's get generateZipForPath() setup and learn how to populate our zip.

Creating a zip archive with JSZip

We're going to showcase two methods for adding files to a zip: one file at a time as well as adding the entire contents of a directory (including its sub-folders). To get started, let's set up our base zip archive and look at how to add a single file:

/lib/generateZipForPath.js

import JSZip from "jszip";

export default (directoryPath = "") => {
  const zip = new JSZip();

  zip.file(
    "standalone.txt",
    "I will exist inside of the zip archive, but I'm not a real file here on the server."
  );
  
  // We'll add more files and finalize our zip here.
};

Here, we define and export a function located at the path we anticipated in the section above. Here, our function takes in a single directoryPath argument specifying the path to the folder we want to add to our zip (this will come in handy in the next step).

In the function body, we kickoff our new zip archive with new JSZip(). Just as it looks, this creates a new zip archive for us in memory.

Just below this, we call to zip.file() passing it the name of the file we'd like to add, followed by the contents we'd like to place in that file. This is important.

The core idea at play here is that we're creating a zip file in memory. We are not writing the zip file to disk (though, if you'd like you can do this with fs.writeFileSync()—see the "converting the zip data" step below for a hint on how to do this).

When we call zip.file() we're saying "create a file in memory and then populat that file, in memory, with these contents." In other words, this file—technically speaking—does not exist. We're generating it on the fly.

/lib/generateZipForPath.js

import fs from "fs";
import JSZip from "jszip";

const addFilesFromDirectoryToZip = (directoryPath = "", zip) => {
  const directoryContents = fs.readdirSync(directoryPath, {
    withFileTypes: true,
  });
 
  directoryContents.forEach(({ name }) => {
    const path = `${directoryPath}/${name}`;

    if (fs.statSync(path).isFile()) {
      zip.file(path, fs.readFileSync(path, "utf-8"));
    }

    if (fs.statSync(path).isDirectory()) {
      addFilesFromDirectoryToZip(path, zip);
    }
  });
};

export default async (directoryPath = "") => {
  const zip = new JSZip();

  zip.file(
    "standalone.txt",
    "I will exist inside of the zip archive, but I'm not a real file here on the server."
  );

  addFilesFromDirectoryToZip(directoryPath, zip);

  // We'll finalize our zip archive here...
};

Now for the tricky-ish part. Remember, we want to learn how to add a single file (what we just accomplished above) as well as how to add a directory. Here, we've introduced a call to a new function addFilesFromDirectoryToZip() passing it the directoryPath argument we mentioned earlier along with our zip instance (our incomplete zip archive).

/lib/generateZipForPath.js

import fs from "fs";
import JSZip from "jszip";

const addFilesFromDirectoryToZip = (directoryPath = "", zip) => {
  const directoryContents = fs.readdirSync(directoryPath, {
    withFileTypes: true,
  });
 
  directoryContents.forEach(({ name }) => {
    const path = `${directoryPath}/${name}`;

    if (fs.statSync(path).isFile()) {
      zip.file(path, fs.readFileSync(path, "utf-8"));
    }

    if (fs.statSync(path).isDirectory()) {
      addFilesFromDirectoryToZip(path, zip);
    }
  });
};

export default async (directoryPath = "") => {
  [...]

  addFilesFromDirectoryToZip(directoryPath, zip);

  // We'll finalize our zip archive here...
};

Focusing in on that function, we can see that it takes in the two arguments we expect: directoryPath and zip.

Just inside the function body, we call to fs.readdirSync(), passing in the given directoryPath to say "go and get us a list of the files inside this directory" making sure to add withFileTypes: true so that we have the full path for each file.

Next, anticipating directoryContents to contain an array of one or more files (returned as objects with a name property representing the file name currently being looped over), we use a .forEach() to iterate over each of the found files, destructuring the name property (think of this like plucking a grape from a bunch where the bunch is the object we're currently looping over).

With that name property, we construct the path to the file, concatenating the directoryPath we passed in to addFilesFromDirectoryToZip() and name. Using this next, we perform the first of two checks to see if the path we're currently looping over is a file.

If it is, we add that file to our zip, just like we saw earlier with zip.file(). This time, though, we pass in the path as the file name (JSZip will automatically create any nested directory structures when we do this) and then we use fs.readFileSync() to go and read the contents of the file. Again, we're saying "at this path in the zip file as it exists in memory, populate it with the contents of the file we're reading."

Next, we perform our second check to see if the file we're currently looping over is not a file, but a directory. If it is, we recursively call addFilesFromDirectoryToZip(), passing in the path we generated and our exsting zip instance.

This may be confusing. Recursion is a programming concept that essentially describes code that "does something until it can't do anything else."

Here, because we're traversing directories, we're saying "if the file you're looping over is a file, add it to our zip and move on. But, if the file you're looping over is a directory, call this function again, passing in the current path as the starting point and then loop over that directory's files, adding each to the zip at its specified path."

Because we're using the sync version of fs.readdir, fs.stat, and fs.readFile, this recursive loop will run until there are no more sub-directories to traverse. This means that once it's complete, our function will "unblock" the JavaScript event loop and continue on with the rest of our generateZipForPath() function.

Converting the zip data to base64

Now that our zip has all of the files and folders we want, let's take that zip and convert it into a base64 string that we can easily send back to the client.

/lib/generateZipForPath.js

import fs from "fs";
import JSZip from "jszip";

const addFilesFromDirectoryToZip = (directoryPath = "", zip) => {
  [...]
};

export default async (directoryPath = "") => {
  const zip = new JSZip();

  zip.file(
    "standalone.txt",
    "I will exist inside of the zip archive, but I'm not a real file here on the server."
  );

  addFilesFromDirectoryToZip(directoryPath, zip);

  const zipAsBase64 = await zip.generateAsync({ type: "base64" });

  return zipAsBase64;
};

Last step on the server. With our zip complete, now we update our exported function to use the async keyword and then call to await zip.generateAsnyc() passing { type: 'base64' } to it to signify that we want to get back our zip file in a base64 string format.

The await here is just a syntax trick (also known as "syntactic sugar") to help us avoid chaining .then() callbacks on to our call to zip.generateAsync(). Further, this makes our asynchronous code read in a synchronous style format (meaning, JavaScript allows each line of code to complete and return before moving to the next line). So, here, we "await" the result of calling zip.generateAsync() and only when it's complete, do we return the value we expect to get back from that function zipAsBase64.

That does it for the server, next, let's jump over to the client and see how to download this to our computer.

Setting up the download on the client

This part is a little bit easier. Let's do a code dump and then step through it:

/pages/zip/index.js

import React, { useState } from "react";
import b64ToBlob from "b64-to-blob";
import fileSaver from "file-saver";

const Zip = () => {
  const [downloading, setDownloading] = useState(false);

  const handleDownloadZip = () => {
    setDownloading(true);

    fetch("http://localhost:5001/zip")
      .then((response) => {
        return response.text();
      })
      .then((zipAsBase64) => {
        const blob = b64ToBlob(zipAsBase64, "application/zip");
        fileSaver.saveAs(blob, `example.zip`);
        setDownloading(false);
      });
  };

  return (
    <div>
      <h4 className="mb-5">Zip Downloader</h4>
      <button
        className="btn btn-primary"
        disabled={downloading}
        onClick={handleDownloadZip}
      >
        {downloading ? "Downloading..." : "Download Zip"}
      </button>
    </div>
  );
};

Zip.propTypes = {};

export default Zip;

Here, we create a dummy React component Zip to give us an easy way to trigger a call to our /zip endpoint back on the server. Using the function component pattern, we render a simple <h4></h4> tag along with a button that will trigger our download when clicked.

To add a bit of context, we've also introduced a state value downloading which will allow us to conditionally disable our button (and change it's text) depending on whether or not we're already trying to download the zip.

Looking at the handleDownloadZip() function, first, we make sure to temporarily disable our button by calling setDownloading() and setting it to true. Next, we make a call to the native browser fetch() method to run a GET request to our /zip endpoint on the server. Here, we're using the default localhost:5001 domain for our URL because that's where the server boilerplate runs by default.

Next, in the .then() callback of our fetch(), we call to response.text() to say "transform the raw response body to plain text." Remember, at this point, we expect our zip to come down to the client as a base64 string. To make that more useful, in the following .then() callback, we make a call to the b64ToBlob() function from the b64-to-blob dependency.

This converts our base64 string into a file blob (a browser-friendly format that represents an operating system file), setting the MIME-type (the encoding method) to application/zip. With this, we import and call to the fileSaver dependency we installed earlier, invoking its .saveAs() method, passing in our blob along with the name we want to use for the zip when it's downloaded. Finally, we make sure to setDownloading() back to false to re-enable our button.

Done! If your server is still running, click the button and you should be prompted to download your zip.

Wrapping up

In this tutorial, we learned how to generate a zip archive using JSZip. We learned how to add both single files to the zip as well as nested directories using a recursive function, and how to convert that zip file to a base64 string to send back to the client. We also learned how to handle that base64 string on the client, converting it into a file blob and saving it to disk with file-saver.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode