Download UploadThing Files as a ZIP
Resolve a user's UploadThing uploads into signed URLs on the server, then fetch and zip them in the browser with Eazip.
UploadThing does not have a built-in "download all" action, and users can only
fetch one file at a time from its URLs. Eazip fills that gap: your backend
resolves the files a user owns into { url, filename } entries with UTApi,
and @eazip/core fetches and zips them in the browser.
Loading live demo…
Pick your path
Where the file URLs already live decides how much of this guide you need.
UploadThing apps often render ufs.sh URLs in the UI already — a gallery or
an attachment list holds usable URLs — so check the first row before building
an endpoint. The main path below assumes the URLs exist only on your server.
The full decision guide
covers every option.
| Your situation | Path |
|---|---|
| The page already renders the files (gallery, attachment list) | Skip the server step and pass those URLs to createZip directly |
| The keys live in your database, or the files are private | Signed URL endpoint plus a browser ZIP — this guide's main path, below |
| Large or private exports on Cloud | A backend-created session, see Other setups |
Build the export endpoint
UploadThing's listFiles() returns every file in your app, not the files that
belong to one user. Most apps already solve this by writing the file key and
name to their own database when an upload completes, so the export endpoint
should read from that table rather than from listFiles() directly.
// app/api/exports/uploads/route.ts
import { NextResponse } from 'next/server';
import { UTApi } from 'uploadthing/server';
import { getSessionUser } from '@/lib/auth';
import { getUploadsForUser } from '@/lib/db';
const utapi = new UTApi();
export async function GET(request: Request) {
const user = await getSessionUser(request);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Your own table, written when each upload completes: which UploadThing
// key belongs to which user, and the original filename.
const uploads = await getUploadsForUser(user.id);
const files = await Promise.all(
uploads.map(async (upload) => {
const { ufsUrl } = await utapi.generateSignedURL(upload.key, {
expiresIn: '10m',
});
return { url: ufsUrl, filename: upload.name };
}),
);
return NextResponse.json(files);
}generateSignedURL() is required for files uploaded with a private ACL. If
your app uses the default public-read ACL, you can skip signing and build
the URL directly:
const url = `https://${process.env.UPLOADTHING_APP_ID}.ufs.sh/f/${upload.key}`;Authorize before you list
Only resolve keys that belong to the authenticated user. listFiles() and
the <APP_ID>.ufs.sh URL format expose whatever key you pass them, so the
authorization check belongs in your own database query, not in the ZIP step.
Create the ZIP in the browser
Fetch the export endpoint, hand the resulting list to createZip, and start
the download:
import { useEazip } from '@eazip/react';
export function ExportButton() {
const zip = useEazip();
const downloadExport = async () => {
const response = await fetch('/api/exports/uploads', {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Could not prepare export files');
}
const files = await response.json();
zip.download({
files,
zipName: 'my-uploads.zip',
});
};
return <button onClick={downloadExport}>Download my uploads</button>;
}Render <EazipTray /> once near your app root — it
shows progress and the finished download.
import { createZip } from '@eazip/core';
const response = await fetch('/api/exports/uploads', {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Could not prepare export files');
}
const files = await response.json();
const result = await createZip({
files,
zipName: 'my-uploads.zip',
});
result.download();UploadThing serves files from ufs.sh with CORS headers that allow
cross-origin browser fetches, so a Local job can read them directly without
proxying bytes through your server. Signed URLs must stay valid until Eazip
fetches them, so set expiresIn generously if the archive is large or the
user's connection is slow.
When to use Eazip Cloud
Local jobs cover most exports. Move to Cloud when:
- a user has thousands of uploads or the combined archive is multi-gigabyte;
- the export should keep running if the user closes or reloads the tab;
- you want the ZIP to build without holding file bytes in tab memory; or
- your backend should create the session so the signed URL list never reaches the browser.
const result = await createZip({
strategy: 'cloud',
publicKey: 'pk_ez_...',
files,
zipName: 'my-uploads.zip',
});
result.downloadAll();Add ufs.sh to the Public App's allowed source hosts, and keep each signed
URL's expiresIn long enough for Eazip Cloud to start and finish the job.
Other setups
Two variations replace the export endpoint entirely.
- Backend-created session — for exports that are large, or whose file list
shouldn't travel to the browser at all: a 1,000-file export otherwise means
signing and shipping 1,000 URLs in one response. With
createSession, your endpoint returns only{ sessionId, clientSecret }and the URL list never crosses the network. See Backend-created sessions. - No browser involved — scheduled or automated exports, such as a nightly archive of a user's uploads, call the HTTP API directly with a secret key and receive a webhook when the ZIP is ready. No frontend SDK takes part.
Limits and alternatives
listFiles()returns app-wide file metadata, not a per-user file list. Track ownership yourself when an upload completes; do not filterlistFiles()results by trusting client-supplied user IDs.- Signed URLs from
generateSignedURL()expire after at most 7 days, so export links you cache for later use will eventually need to be regenerated. - If a user only has a handful of files, direct per-file download links may be simpler than a ZIP; reserve this workflow for exports of several files or more.
FAQ
Can users download all their UploadThing files at once?
Not directly from UploadThing. Build a server endpoint that resolves the
user's file keys into signed or public URLs, then pass that list to
createZip so the browser fetches and archives them together.
Does UploadThing support bulk downloads natively?
No. UTApi can list and delete files, but it has no ZIP or bulk-export
feature. Eazip handles the archiving step once your endpoint returns the file
URLs.
How do I zip private UploadThing files?
Call utapi.generateSignedURL(key, { expiresIn }) for each file on the
server and pass the resulting ufsUrl values to createZip. Never call
generateSignedURL from browser code, since it requires your UploadThing
secret key.
Will this work if my UploadThing files are public?
Yes. Public (public-read) files can be zipped from their standard
https://<APP_ID>.ufs.sh/f/<key> URL without generating a signed URL first.
What if a user has thousands of uploaded files?
Use strategy: 'cloud' instead of the default Local job. Cloud fetches the
URLs outside the browser tab, so archive size and file count are no longer
limited by tab memory or lifetime.
Next steps
- Create a ZIP from remote URLs covers the general URL-list workflow this guide builds on.
- Input types documents every shape
createZipaccepts, including mixed File and URL lists. - When to use Eazip Cloud explains the full Local versus Cloud trade-off.