9 min read

How to add file uploads to a shadcn/ui app in Next.js

A step-by-step guide to adding real file uploads to a shadcn/ui project in Next.js, from the first dropzone to direct-to-S3 uploads, forms and server actions, using the open-source UploadCN registry.

  • UploadCN
  • shadcn/ui
  • Next.js
  • File uploads
  • Tutorial
How to add file uploads to a shadcn/ui app in Next.js, an article by Muhammad Hanzala

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:

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

Step 1: Start from a shadcn project

If your app already has a components.json, skip this. Otherwise:

npx shadcn@latest init

UploadCN 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-upload

You 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:

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-route

That 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-config

This 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:

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-field

UploadField 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:

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

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.

Frequently asked questions

Does shadcn/ui have a file upload component?

No. shadcn/ui ships inputs, buttons and progress bars but no file upload. UploadCN is an open-source registry that adds upload components and blocks in the shadcn style, installed with npx shadcn@latest add @uploadcn/file-upload.

How do I upload files in Next.js without hitting the server action size limit?

Upload directly from the browser to your storage with a presigned URL, then submit only the object keys to your server action. Next.js limits server action bodies to 1 MB by default, and the keys are just a few short strings.

Can I use UploadCN with react-hook-form?

Yes. The upload-field block works with react-hook-form and TanStack Form. Its onChange receives the keys of finished uploads, and onUploadingChange tells you when files are still uploading so you can disable submit.

What CORS settings does direct-to-S3 upload need?

Allow PUT from your site's origin, allow the content-type header, and expose the ETag header. Without ExposeHeaders ETag, multipart uploads fail at the final step.

Case studyUploadCN: how I designed, built and shipped it

Building something that needs this kind of care?

I take products from idea to production.

Start a project