shadcn/ui gives you a beautiful Input, a Button and a Progress bar. It doesn't give you a file upload. So most of us search "shadcn file upload", find a snippet with <input type="file"> and a server action, paste it in, and move on.
That snippet works on localhost. In production it runs into three walls quickly:
- Body size limits. Next.js caps server action bodies at 1 MB by default, and most serverless hosts cap request bodies at a few megabytes. One phone photo can go over that.
- No progress. A
fetchor a form post doesn't report upload progress. People see a spinner and hit the button again. - No recovery. When the connection drops halfway through, the whole file starts over, if anyone notices it failed at all.
This guide fixes all three. I'll use UploadCN, the open-source upload kit for shadcn/ui I built, because it installs the shadcn way and handles the hard parts underneath. Even if you end up writing your own, the steps and the checklist at the end apply to any upload flow.
What you'll end up with
- A dropzone with a real upload queue: progress, speed, time remaining, retry and cancel.
- Files going straight from the browser to your S3 or R2 bucket, so they never pass through your Next.js server.
- A server route that decides who can upload, how big, and what types.
- A form that submits object keys instead of files, so your server action stays tiny.
Step 1: Start from a shadcn project
If your app already has a components.json, skip this. Otherwise:
npx shadcn@latest initUploadCN works with any project set up with the shadcn CLI, Next.js included, with Radix or Base UI primitives and Tailwind CSS v4.
Step 2: Add the upload block
npx shadcn@latest add @uploadcn/file-uploadYou don't need to configure a registry URL first. UploadCN is listed in the official shadcn registry directory, so the CLI already knows the @uploadcn namespace.
Two files land in your project, and two small packages get installed:
components/ui/upload.tsx: the composable upload primitives (dropzone, queue, item, progress, actions). This is your code now, so restyle it however you like.components/file-upload.tsx: the finished block built from those primitives.@uploadcn/coreand@uploadcn/react: the upload engine and its React bindings. The engine is a package on purpose. Retries, multipart and resume are full of edge cases, and you want fixes for those to arrive withnpm update.
The block arrives in your project's style, whichever of the eight shadcn styles you picked, and its icons are converted to the icon library in your components.json. You won't end up with two icon sets in one bundle.
Step 3: Decide where the files go
Every UploadCN component uploads through an adapter. The UI doesn't care where bytes end up, so you can change this later without touching a single component.
| You have… | Use |
| --- | --- |
| An S3, R2, MinIO, B2 or Spaces bucket | s3Adapter and a small route from @uploadcn/server |
| Cloudinary | cloudinaryAdapter, no route needed with an unsigned preset |
| Your own upload endpoint | httpAdapter |
| Supabase, Firebase, Vercel Blob or any SDK | createAdapter around your upload function |
| No backend yet | mockAdapter, for realistic uploads in prototypes |
| Only the UI, your form submits the files | localAdapter |
For most production apps, a bucket is the right answer, so that's what the rest of this guide uses. Add the route:
npx shadcn@latest add @uploadcn/upload-routeThat creates app/api/upload/route.ts. Fill in your credentials:
S3_BUCKET=my-uploads
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=…
S3_SECRET_ACCESS_KEY=…Then allow PUT from your site in the bucket's CORS settings, and expose the ETag header:
[
{
"AllowedOrigins": ["https://your-app.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]Don't skip ExposeHeaders. Large files are uploaded in parts, and the browser has to read each part's ETag to finish the upload. Without it, everything looks fine until the very last request fails. I go much deeper on this, and on Cloudflare R2, in Direct-to-S3 and R2 uploads in Next.js.
Step 4: Configure storage once
You could pass an adapter to every component, but it's easier to set it once for the whole app:
npx shadcn@latest add @uploadcn/upload-configThis creates a small provider component. Point it at your route:
"use client"
import { s3Adapter } from "@uploadcn/core"
import { UploadConfigProvider } from "@uploadcn/react"
const adapter = s3Adapter({ endpoint: "/api/upload" })
export function UploadConfig({ children }: { children: React.ReactNode }) {
return (
<UploadConfigProvider adapter={adapter} maxSize={20_000_000}>
{children}
</UploadConfigProvider>
)
}Wrap your root layout in <UploadConfig>. Every upload component below it now uses that adapter and those defaults. A single component can still override it with its own adapter prop, and providers nest, so an admin area can have its own bucket or larger limits.
If you forget this step, components throw an error that tells you how to configure an adapter. There's no hidden default endpoint quietly sending files somewhere.
Step 5: Render the uploader
Upload components use hooks, so they're Client Components, and the registry files already include "use client". You render them from Server Components like anything else:
import { FileUpload } from "@/components/file-upload"
export default function DocumentsPage() {
return <FileUpload accept="image/*,application/pdf" maxSize={20_000_000} maxFiles={10} />
}That one line gets you quite a lot. Files are checked in the browser, queued three at a time, uploaded straight to your bucket through /api/upload, retried on network errors, and shown with progress, speed and time remaining.
Step 6: Lock it down on the server
Client-side checks are a convenience. Your route is the source of truth. Here's the same route with real rules:
import { UploadRouteError, createUploadRoute, s3Storage } from "@uploadcn/server"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
export const { POST } = createUploadRoute({
storage: s3Storage({
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
}),
maxFileSize: 500 * 1024 * 1024,
allowedTypes: ["image/*", "application/pdf"],
async authorize({ key }) {
const session = await auth()
if (!session) throw new UploadRouteError("Unauthorized", 401)
if (key && !key.startsWith(`${session.user.id}/`)) throw new UploadRouteError("Forbidden", 403)
return session.user
},
getKey: ({ auth, file }) => `${auth.id}/${crypto.randomUUID()}/${file.name}`,
async onUploadComplete({ auth, key, file }) {
return db.file.create({ data: { userId: auth.id, key, name: file.name, size: file.size } })
},
})A few things are happening here that are easy to get wrong by hand:
authorizeruns before every action, including each part of a multipart upload. The key check stops one user from finishing another user's upload.- Size and type are checked before anything is signed, and the signature itself locks the content type and length. S3 rejects a body that doesn't match.
- Object keys come from
getKeyon the server. Never trust a file path sent by the browser. onUploadCompleteonly runs after the route confirms the object really exists in the bucket, so nobody can register a file they never uploaded.
Step 7: Use it in a form
The pattern that works best is simple: upload while the person fills in the form, then submit the object keys. Your form value becomes a list of strings, validation is easy, and submitting is instant because the files are already stored.
With a native form and a server action, give Upload a name. Each finished upload renders a hidden input holding its key:
async function createPost(formData: FormData) {
"use server"
const images = formData.getAll("images") // object keys
await db.post.create({ data: { images } })
}
<form action={createPost}>
<Upload adapter={adapter} name="images">…</Upload>
<button type="submit">Publish</button>
</form>This also sidesteps the 1 MB server action limit completely, because the action only ever receives a few short strings.
If you use react-hook-form or TanStack Form, add the field block instead:
npx shadcn@latest add @uploadcn/upload-fieldUploadField is controlled. onChange receives the keys of successful uploads, and onUploadingChange tells you when files are still in flight, so you can disable the submit button until they finish. It also wires up the label, description, error message and aria-invalid for you.
Building your own layout
FileUpload is just one composition of the primitives. When a design calls for something different, compose them yourself:
import { s3Adapter } from "@uploadcn/core"
import { Upload, UploadDropzone, UploadDropzoneTitle, UploadQueue } from "@/components/ui/upload"
const adapter = s3Adapter({ endpoint: "/api/upload" })
export function Attachments() {
return (
<Upload adapter={adapter} accept={["image/png", "image/jpeg"]} maxSize={10 * 1024 * 1024} maxFiles={5}>
<UploadDropzone>
<UploadDropzoneTitle>Drop images here</UploadDropzoneTitle>
</UploadDropzone>
<UploadQueue />
</Upload>
)
}And when other parts of the page need to react to uploads, like a Publish button that waits for them, create the engine with useUpload and pass it in:
const { uploader, summary } = useUpload({ adapter })
<Upload uploader={uploader}>
<UploadDropzone />
<UploadQueue />
</Upload>
<Button disabled={summary.isUploading}>Publish</Button>Pick the block that fits your screen
There are 58 blocks, so there's a good chance the one you need already exists. The ones I reach for most:
@uploadcn/avatar-upload: profile photos with round cropping and a progress ring.@uploadcn/image-upload: an image grid with optional cropping and compression in the browser.@uploadcn/document-upload: documents, PDFs and résumés with type badges.@uploadcn/chat-attachments: an AI chat composer that takes files by click, drag and paste.@uploadcn/global-upload: an app-wide provider with a drop-anywhere overlay and a background queue that survives navigation.@uploadcn/csv-import: reads the CSV in the browser and checks the columns before anything uploads.
Each one installs the same way, with npx shadcn@latest add. When a block changes upstream, npx shadcn@latest add @uploadcn/<name> --diff shows you what's new before you take it, so updating never silently overwrites your edits.
Checklist before you ship
- Bucket CORS allows
PUTfrom your real domain and exposesETag. - The route checks auth,
maxFileSizeandallowedTypes, and generates keys on the server. - A bucket lifecycle rule aborts incomplete multipart uploads after a few days, or you pay to store abandoned parts.
- Your form submits keys, not files.
- You tried it with the keyboard alone. The dropzone is a real button, so Enter or Space should open the file picker.
- You tried it with a slow, flaky connection. Throttle the network in DevTools and pull the cable halfway through.
That last one is where homemade uploaders usually fall apart, and where a tested engine earns its place. If you want the background on why UploadCN is built the way it is, read why I built UploadCN, or browse every block live at uploadcn.dev/examples.