Downloading Multiple Files at Once
Handling multiple file downloads can be messy. Bundling them into a single ZIP file on the client using JSZip improves UX and simplifies the process.
Downloading Multiple Files at Once: Create ZIP on Client (JSZip + Next.js)
Summary
- Downloading files individually is cumbersome and may trigger browser warnings about "multiple file downloads".
- Simplify this by bundling multiple files into a single ZIP download using JSZip on the client side.
- The flow is the same: Create Blob → Create Object URL → Trigger Download via <a download> → Revoke URL.
Background/Problem
Even scenarios like "1 CSV + 1 Image" can be annoying for users if downloaded separately.
- Download buttons/logic multiply.
- Multiple downloads may trigger browser permission prompts.
- Users have to find multiple files in their Downloads folder.
The solution is "Bundle into one ZIP and download once".
Core Concepts
Blob (Binary Large Object)
A "file-like object of immutable raw data". You can create Blobs from text, binary data, images, etc.
Object URL (URL.createObjectURL)
URL.createObjectURL(blob) creates a temporary URL pointing to the Blob. This URL can be set to <a href="..."> to trigger a download. After use, you must call URL.revokeObjectURL(url) to release memory.
JSZip
A library for creating ZIP files in the browser. You use zip.file() and zip.folder() to structure the content, then generateAsync({ type: "blob" }) to create a ZIP Blob.
Approach
- Prepare data to be filed (CSV string, images, etc.).
- Add files/folders to JSZip.
- Generate ZIP as a Blob.
- Create an Object URL and trigger download via
<a download>.
Implementation (Code)
Since it uses browser APIs (window, document, canvas, URL), run this in a Client Component in Next.js.
0) Installation
npm i jszip1) Download Utility: Blob → <a download>
// app/download-zip/downloadBlob.ts\nexport function downloadBlob(blob: Blob, filename: string) {\n const url = URL.createObjectURL(blob);\n\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = filename;\n\n // Append to body to ensure stability in some environments\n document.body.appendChild(a);\n a.click();\n a.remove();\n\n // Revoke URL on next tick to prevent premature cleanup\n setTimeout(() => URL.revokeObjectURL(url), 0);\n}2) Create ZIP + Download (CSV + Canvas Image)
// app/download-zip/DownloadZipClient.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { downloadBlob } from "./downloadBlob";
const CSV_TEXT =
"policyID,statecode,county\n" +
"119736,FL,CLAY COUNTY\n" +
"448094,FL,CLAY COUNTY\n";
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality?: number) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("canvas.toBlob() failed"))),
type,
quality
);
});
}
export default function DownloadZipClient() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [downloading, setDownloading] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Simple demo drawing
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "red";
ctx.font = "24px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Hello World", canvas.width / 2, canvas.height / 2);
}, []);
async function downloadAllAsZip() {
if (downloading) return;
const canvas = canvasRef.current;
if (!canvas) return;
setDownloading(true);
try {
// Dynamic import to reduce initial bundle size
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
// 1) CSV: String → Blob
const csvBlob = new Blob([CSV_TEXT], { type: "text/csv;charset=utf-8" });
zip.file("test.csv", csvBlob);
// 2) Image: canvas → Blob
const imageBlob = await canvasToBlob(canvas, "image/jpeg", 0.92);
const imgFolder = zip.folder("images");
imgFolder?.file("resized.jpeg", imageBlob);
// 3) Generate ZIP
const zipBlob = await zip.generateAsync({ type: "blob" });
// 4) Download
downloadBlob(zipBlob, "example.zip");
} finally {
setDownloading(false);
}
}
return (
<div style={{ padding: 16 }}>
<h1>ZIP Download Demo</h1>
<canvas
ref={canvasRef}
width={600}
height={240}
style={{ border: "1px solid #ccc" }}
/>
<div style={{ marginTop: 12 }}>
<button onClick={downloadAllAsZip} disabled={downloading}>
{downloading ? "Creating ZIP..." : "Download CSV + Image as ZIP"}
</button>
</div>
</div>
);
}Checklist
example.zip?test.csv and images/resized.jpeg?URL.revokeObjectURL(url) called after download?FAQ
Q1. Download fails if I revoke Object URL immediately.
While it should be revoked, doing it too quickly can interrupt the download in some browsers. Using setTimeout(..., 0) to defer revocation is a common workaround.
Q2. Safari/iOS behavior is different.
a.download is not guaranteed to work everywhere. Always test on actual devices, especially iOS.
Q3. Browser lags with many files.
JSZip runs on the main thread (mostly). Generating large ZIPs can freeze the UI. Consider UI feedback (progress bar), reducing size, or moving compression to the server if necessary.
Conclusion
Bundling downloads into a single ZIP using Client-side JSZip significantly improves UX and simplifies file management.