Build a shadcn file manager with uploads, folders, previews, and progress
One page of Files SDK shadcn components that browses folders, uploads into them with progress, previews files, pages long folders, and retries failures.
Five registry components share one useFiles() instance. File Browser lists folders with a delimiter listing and pages with Load more, Dropzone uploads into the open folder, Upload Progress shows each transfer, File Preview renders the selected file, and File Actions gives every row download, copy, rename, move, and delete. Your gateway’s authorize hook scopes every call to the signed-in user, so the page only ever sees keys relative to that user’s prefix.
File Browser reports the open folder through its onNavigate prop, so the page can point the dropzone at it. One part needs a decision from you: an upload into a folder uses an explicit key, which streams the bytes through your route instead of straight to storage, and on Vercel that caps each file at 4.5 MB. Choose where uploads go covers the tradeoff.
Before you start
- A Next.js App Router app with shadcn/ui initialized. The components import from
@/components/uiand@/lib/utils. - The storage setup from Build a Next.js file uploader with Cloudflare R2:
lib/files.ts, theR2_*andFILES_API_SECRETvariables, and the bucket’s CORS rule, withGETadded toAllowedMethods. File Preview and the Download action fetch files from JavaScript, and thatfetchfollows the gateway’s redirect to R2. Any signing adapter’s bucket needs the same rule. - An auth library that can resolve the signed-in user on the server.
- Written against files-sdk 3.0, Next.js 16.4, React 19.3, and the registry components as published on files-sdk.dev. The page below was rendered under happy-dom against the gateway and the memory adapter to check the folder, pagination, upload, retry, and error flows. It hasn’t been run against R2.
Install the components
npx shadcn@latest add https://files-sdk.dev/r/file-browser.json https://files-sdk.dev/r/dropzone.json https://files-sdk.dev/r/upload-progress.json https://files-sdk.dev/r/file-preview.json
The files land in components/files-sdk/. Each registry item declares files-sdk and lucide-react as npm dependencies and the shadcn primitives it uses (button, progress, dialog, dropdown-menu, input) as registry dependencies. File Browser also depends on File Actions, its per-row menu, so you don’t add that one separately.
The components are your source code now, so you can change anything their props don’t cover.
Scope the gateway to the signed-in user
The Next.js R2 guide walks through the gateway. The file manager needs the same route with a different list of verbs and one extra constraint:
import { FilesError } from "files-sdk";
import { createFilesRouter, type FilesOperation } from "files-sdk/api";
import { createRouteHandler } from "files-sdk/next";
import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";
// Every verb the file manager's components call, and nothing else.
const ALLOWED = new Set<FilesOperation>([
"capabilities",
"list",
"head",
"url",
"download",
"upload",
"copy",
"move",
"delete",
]);
const router = createFilesRouter({
files,
authorize: async ({ operation, req }) => {
const session = await getSession(req.headers);
if (!session) {
throw new FilesError("Unauthorized", "Sign in to manage files");
}
if (!ALLOWED.has(operation)) {
throw new FilesError("ReadOnly", `${operation} is not allowed`);
}
return {
keyPrefix: `users/${session.user.id}/`,
maxExpiresIn: 300,
maxResults: 50,
};
},
});
export const { GET, POST, PUT } = createRouteHandler(router);
getSession stands in for your auth library (Auth.js, Clerk, Better Auth, or your own cookie check). This guide assumes it takes request headers and returns { user: { id: string } } or null.
capabilitiesis on the list on purpose. File Browser asks for it once on mount to decide between a folder listing and a flat one, and File Preview asks before it shows an image.authorizeruns forcapabilitieslike any other verb. If it throws, File Browser falls back to a folder listing, but File Preview shows the error (capabilities is not allowed) where the image should be.maxResultssets the page size. File Browser doesn’t pass alimit, so without it each page holds up to the gateway’smaxListLimit, 1,000 entries by default. With50, a folder of 60 files shows 50 rows and a Load more button that fetches the other 10.keyPrefixkeeps users apart. Every key the components send getsusers/<id>/prepended on the server, and every key they receive has it stripped. Isolate each tenant’s files shows what the gateway returns when a user tries to reach outside their prefix.
Build the page
"use client";
import type { FileInfo } from "files-sdk";
import { useFiles } from "files-sdk/react";
import { useState } from "react";
import {
Dropzone,
DropzoneContent,
DropzoneEmptyState,
DropzoneError,
} from "@/components/files-sdk/dropzone";
import { FileBrowser } from "@/components/files-sdk/file-browser";
import { FilePreview } from "@/components/files-sdk/file-preview";
import { UploadProgress } from "@/components/files-sdk/upload-progress";
import { Button } from "@/components/ui/button";
const MAX_BYTES = 25 * 1024 * 1024;
export function FileManager() {
const files = useFiles();
const [folder, setFolder] = useState("");
const [selected, setSelected] = useState<FileInfo | null>(null);
const failed = files.uploads.filter((upload) => upload.status === "error");
async function retryFailed() {
const retries = failed.flatMap((upload) =>
upload.file instanceof Blob
? [{ file: upload.file, key: upload.key }]
: []
);
files.reset(); // clears the finished rows, including the failed ones
await Promise.allSettled(
retries.map(({ file, key }) =>
key
? files.upload(key, file, { contentType: file.type })
: files.upload(file)
)
);
}
return (
<div className="grid gap-6 md:grid-cols-[1fr_20rem]">
<section aria-label="Files" className="flex flex-col gap-4">
<Dropzone
files={files}
maxFiles={10}
maxSize={MAX_BYTES}
prefix={folder}
>
<DropzoneEmptyState />
<DropzoneContent />
<DropzoneError />
</Dropzone>
<UploadProgress files={files} />
{failed.length > 0 && !files.isUploading && (
<Button onClick={retryFailed} type="button" variant="outline">
Retry {failed.length} failed{" "}
{failed.length === 1 ? "upload" : "uploads"}
</Button>
)}
<FileBrowser
files={files}
onChanged={() => setSelected(null)}
onNavigate={setFolder}
onSelect={setSelected}
/>
</section>
<aside aria-label="Preview">
{selected ? (
<FilePreview file={selected} files={files} />
) : (
<p className="text-muted-foreground text-sm">
Select a file to preview it.
</p>
)}
</aside>
</div>
);
}
import { FileManager } from "./file-manager";
export default function FilesPage() {
return (
<main className="mx-auto max-w-5xl p-6">
<h1 className="mb-6 text-xl font-semibold">Files</h1>
<FileManager />
</main>
);
}
Render the page behind your sign-in. How the pieces connect:
- One
useFiles()instance. Upload Progress reads the hook’suploadsandprogress, so it shows every upload the dropzone starts without any wiring between them. - Folders. File Browser calls
onNavigatewith the open folder’s prefix on mount and whenever the user opens another folder, sofolderfollows the breadcrumb and the dropzone’sprefixfollowsfolder. Folder rows are theprefixesof alist({ delimiter: "/" }); on adapters with no folder concept, File Browser lists every key under the path instead and says so. - Refreshing after uploads. File Browser re-lists the open folder when you navigate, after its own row actions, and when uploads through its
filesinstance finish. A dropzone batch re-lists once, after its last file, and the rows on screen stay until the new listing replaces them. The listing starts again from its first page, so rows you loaded with Load more have to be loaded again. - Previews.
onSelecthands over theFileInfofrom the listing, and passing that object (not a key string) to File Preview saves aheadrequest. Images load from a signed URL, PDFs are downloaded and shown from ablob:URL, and text renders inline. File Preview explains why PDFs take the long way.onChangedclears the selection after a rename, move, or delete, since the file may no longer be there.
Choose where uploads go
Dropzone picks its upload path from prefix, and the two paths behave differently:
| Open folder | What Dropzone calls | Stored key | Where the bytes go |
|---|---|---|---|
The root (prefix is "") |
files.upload(file) |
<uuid>.<ext>, minted by the server |
Browser to R2, on a presigned PUT |
A folder, such as docs/ |
files.upload("docs/" + file.name, file, { contentType }) |
docs/<original name> |
Browser to your route, then to R2 |
That table has consequences for a file manager:
- Root uploads get random names. The gateway mints keyless keys, so a file dropped at the root shows up as something like
0353547a-….txt. Users can rename it from the actions menu. - Folder uploads overwrite. An explicit-key upload is a plain write, so dropping
report.pdfinto a folder that already has one replaces it without asking. Callfiles.exists()first if that matters. - Folder uploads pass through your server. On Vercel, function request bodies are capped at 4.5 MB, so larger files fail with a
413. The R2 adapter’sfetchengine also buffers each streamed body in memory before writing it. Fix Vercel’s 413 upload error covers the platform side. - Progress measures a different hop. For a folder upload, the bar tracks bytes reaching your route, so it can sit at 100% while your server finishes writing to R2. For a root upload, it tracks bytes reaching R2.
- CORS differs. Root uploads are cross-origin
PUTs to R2 and need the bucket’s CORS rule. Folder uploads are same-origin requests to/api/files.
On both paths the dropzone uploads one file at a time, and the client reports progress from XMLHttpRequest upload events. If your users upload large files or you deploy to a platform with a request body limit, keep uploads at the root and let users file them into folders with Move.
Retry failed uploads
A failed upload stays in files.uploads with status: "error", its error, and its original file. That’s enough to send it again. retryFailed collects those entries, clears the finished rows with files.reset(), and uploads each file again:
- A folder upload already has its
key, so the retry writes the samedocs/<name>. It passes the file’s owntype, as the dropzone did. A differentcontentTypemakes the client wrap the file in a new, namelessBlob, and Upload Progress would list the retry asblob. - A root upload that failed before the presign step has no key and retries as a keyless upload. One that failed after the presign step already holds a server-minted key, so its retry sends the bytes through your route to that key.
Files the dropzone refuses before uploading, because they don’t match accept or exceed maxSize, never become uploads entries, so Retry can’t resend them. The retry runs through the same useFiles() instance as the dropzone, so DropzoneError clears as soon as it starts, Upload Progress shows the new rows, and File Browser re-lists the folder when they finish.
maxSize is a check in the browser that gives fast feedback, not a limit. To enforce one, see Enforce file-size and content-type limits on presigned uploads.
Show errors where they happen
Each component reports its own failures:
- Listing. A failed
listshowsCouldn't list this folder:and the message, with a Try again button, in arole="alert"block. File Browser never shows a failed listing as an empty folder. - Uploads. Upload Progress marks the row
Failed:with the message,DropzoneErrorsummarizes the batch, and the page adds the Retry button. Both components also announce the failure to screen readers. - Copy, rename, move, and delete. The File Actions dialog stays open and shows the error, so the user can fix the destination and try again.
- Download from the actions menu. This one has no UI of its own. The error only reaches
files.error, which holds the last error from any verb. If you render it, expect it to repeat errors the components already show.
Keyboard and screen readers
The upload works without a mouse. The dropzone is a native <button type="button">, so it’s in the tab order, and Enter or Space activates it. Activating it calls click() on a hidden file input, the pattern MDN documents, which opens the system file picker. Dragging is optional. While uploads run, the button gets aria-disabled="true" and ignores activation but stays focusable. Folder rows, breadcrumbs (a nav with aria-current="page" on the open folder), file rows, and Load more are native buttons too, and the File Actions menu and dialogs are keyboard-operable shadcn primitives.
The components also tell screen reader users what happened:
- The dropzone’s name is its prompt. The success and error summaries are hidden from its name and set as its description, so focusing it reads the prompt first and the latest outcome after it. Errors are announced from a polite live region beside the button when they happen.
- Upload Progress names each bar after its file, with the row’s status as the value text, and a polite live region announces each file as it finishes, fails, or is cancelled. Progress ticks and
files.reset()stay quiet. - Each row’s menu trigger names its key, such as “Actions for docs/report.pdf”, so the triggers can be told apart.
A failed dropzone upload is announced twice, once for the file by Upload Progress and once in the dropzone’s batch summary. Successful uploads are announced once, by Upload Progress.
Troubleshooting
The image preview shows capabilities is not allowed. authorize refused the capabilities operation. Add it to the allowed verbs.
Text and PDF previews or the Download action fail with Failed to fetch, and the console shows a CORS error for an R2 URL. The gateway redirected the fetch to a signed R2 URL, and the bucket’s CORS rule doesn’t allow GET. Add it to AllowedMethods.
Uploads into folders fail with a 413 on Vercel. The file exceeds the 4.5 MB function request body limit. Upload at the root, where bytes go straight to R2.
Root uploads fail with network error during upload. The browser blocked the cross-origin PUT to R2. Check the bucket’s CORS rule as described in the Next.js R2 guide.
Couldn't list this folder: Sign in to manage files. getSession returned null, so the gateway answered 401. Check that the session cookie reaches route handlers.