EmpireUI
Get Pro
← Blog8 min read#uploadthing#file upload#next.js

UploadThing Guide: File Uploads in Next.js Without the S3 Config Pain

Skip the S3 bucket policies and IAM roles. UploadThing gives you type-safe file uploads in Next.js in about 20 lines of code — here's exactly how.

abstract colorful digital file upload interface on dark background

Why UploadThing Exists (and Why You Should Care)

Every Next.js app eventually needs file uploads. And every developer who's gone the S3 route knows the ritual: create a bucket, untangle CORS headers, write a presigned URL endpoint, handle the multipart edge cases, debug why 403 Forbidden keeps appearing even though the policy looks correct. It's a two-hour yak shave at minimum.

UploadThing — released in 2023 by the T3 Stack team — cuts all of that out. You define what files you accept, you get a type-safe client hook, and it handles the rest. The storage backend is abstracted away completely. You're not touching IAM roles ever again.

Honestly, the mental model shift is the best part. Instead of thinking about infrastructure, you think about your actual product requirements: which routes accept which file types, what size limits make sense, who's allowed to upload. That's where your brain should be spending cycles.

Worth noting: UploadThing doesn't replace S3 — it uses it under the hood on their infrastructure. What you're buying is the configuration layer and the type-safe DX on top. If you need exotic bucket configurations or you're already all-in on your own AWS account, that's a valid reason to still go direct. For most apps, though? This is the move.

Installation and Initial Setup

You need Next.js 13+ with the App Router. The Pages Router works too but the ergonomics are noticeably worse — just use App Router at this point. Install the package:

npm install uploadthing @uploadthing/react

Then grab your API key from the UploadThing dashboard at uploadthing.com. Drop it in your .env.local: ``bash UPLOADTHING_SECRET=sk_live_... UPLOADTHING_APP_ID=your_app_id ``

One more thing — as of uploadthing@6.0, the package ships a Next.js adapter directly, so you don't need a separate @uploadthing/next dependency anymore. The API changed significantly between v5 and v6, so if you're following an older tutorial, you'll hit errors. Stick to the official docs for the current version.

Creating Your File Router

The core abstraction in UploadThing is the "file router" — a server-side object that maps route names to upload configurations. Think of it like tRPC for file uploads. You define the route, it gives you a typed client.

Create app/api/uploadthing/core.ts: ``typescript import { createUploadthing, type FileRouter } from 'uploadthing/next'; const f = createUploadthing(); export const ourFileRouter = { // A route for profile photos profilePicture: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } }) .middleware(async ({ req }) => { // Throw if unauthenticated — UploadThing surfaces this as a 401 const session = await getServerSession(); if (!session) throw new Error('Unauthorized'); return { userId: session.user.id }; }) .onUploadComplete(async ({ metadata, file }) => { console.log('Upload complete for userId:', metadata.userId); console.log('File URL:', file.url); // Save file.url to your database here return { uploadedBy: metadata.userId }; }), // A route for document uploads documentUpload: f({ pdf: { maxFileSize: '16MB' }, 'application/msword': { maxFileSize: '16MB' } }) .middleware(async () => ({ timestamp: Date.now() })) .onUploadComplete(async ({ file }) => { console.log('Document URL:', file.url); }), } satisfies FileRouter; export type OurFileRouter = typeof ourFileRouter; ``

The .middleware() function runs on your server before the upload starts. It's where you put auth checks, rate limiting, or any per-user data you want to pass through to onUploadComplete. Anything you return from middleware becomes available as metadata in the completion handler. That pattern is elegant and it's one of the things that makes UploadThing feel thought-through.

Then wire up the route handler at app/api/uploadthing/route.ts: ``typescript import { createRouteHandler } from 'uploadthing/next'; import { ourFileRouter } from './core'; export const { GET, POST } = createRouteHandler({ router: ourFileRouter, }); ``

That's it for the server side. Two files, maybe 40 lines total. Compare that to the 150-line presigned URL setup you'd write for raw S3 — and that version doesn't even include the auth layer.

Building the Upload UI Component

UploadThing ships pre-built React components. They work, they handle the upload progress state, and they're accessible out of the box. But they're also heavily opinionated about styling — you'll likely want to customize them or build your own using the useUploadThing hook instead.

First, generate your typed components in utils/uploadthing.ts: ``typescript import { generateUploadButton, generateUploadDropzone, generateUploader } from '@uploadthing/react'; import type { OurFileRouter } from '@/app/api/uploadthing/core'; export const UploadButton = generateUploadButton<OurFileRouter>(); export const UploadDropzone = generateUploadDropzone<OurFileRouter>(); ``

Now you can drop these into any client component: ``tsx 'use client'; import { UploadDropzone } from '@/utils/uploadthing'; export function ProfilePictureUploader() { return ( <UploadDropzone endpoint="profilePicture" onClientUploadComplete={(res) => { console.log('Files:', res); // res[0].url is the CDN URL alert('Upload complete!'); }} onUploadError={(error: Error) => { alert(Error: ${error.message}); }} /> ); } ``

The endpoint prop is fully typed — TypeScript will yell at you if you reference a route that doesn't exist in your OurFileRouter. That's the killer feature. No magic strings, no runtime surprises. In practice, this is what saves you from the classic 'endpoint name typo' bug at 2am.

Quick aside: if you want to fit UploadThing's dropzone into a custom design system — like something built on Empire UI or styled with glassmorphism from the glassmorphism components — use useUploadThing hook directly instead of the pre-built components. You get the full upload logic without any of the imposed styling.

Custom Hook for Maximum Control

When the pre-built components don't cut it, useUploadThing gives you direct access to the upload machinery. This is what you want when you're building a drag-and-drop zone from scratch, integrating with a design system, or building something like an image gallery uploader with custom progress indicators.

'use client';
import { useCallback, useState } from 'react';
import { useUploadThing } from '@uploadthing/react';
import type { OurFileRouter } from '@/app/api/uploadthing/core';

export function CustomUploader() {
  const [uploadProgress, setUploadProgress] = useState(0);
  const [uploadedUrl, setUploadedUrl] = useState<string | null>(null);

  const { startUpload, isUploading } = useUploadThing<OurFileRouter>('profilePicture', {
    onUploadProgress: (p) => setUploadProgress(p),
    onClientUploadComplete: (res) => {
      setUploadedUrl(res?.[0]?.url ?? null);
    },
    onUploadError: (err) => console.error(err),
  });

  const handleFileChange = useCallback(
    async (e: React.ChangeEvent<HTMLInputElement>) => {
      const files = Array.from(e.target.files ?? []);
      if (files.length) await startUpload(files);
    },
    [startUpload]
  );

  return (
    <div className="flex flex-col gap-4">
      <input
        type="file"
        accept="image/*"
        onChange={handleFileChange}
        disabled={isUploading}
        className="block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:bg-violet-50 file:px-4 file:py-2 file:text-sm file:font-semibold hover:file:bg-violet-100"
      />
      {isUploading && (
        <div className="h-2 w-full overflow-hidden rounded-full bg-gray-200">
          <div
            className="h-full bg-violet-500 transition-all duration-300"
            style={{ width: `${uploadProgress}%` }}
          />
        </div>
      )}
      {uploadedUrl && (
        <img src={uploadedUrl} alt="Uploaded" className="h-48 w-48 rounded-lg object-cover" />
      )}
    </div>
  );
}

The isUploading boolean is your best friend here. Disable your submit buttons, show spinners, lock the form — all of it driven by a single reactive value. No manual state machines.

Look, this is 40px worth of custom progress bar that actually reflects real upload progress, not a fake animation. That matters for large files. Users can tell the difference between a real progress bar and a fake one.

Handling the Server Callback and Saving to Your Database

The onUploadComplete callback on your router runs on your server after UploadThing confirms the upload. This is where you save the file URL to your database — Prisma, Drizzle, raw SQL, whatever you're using.

// In your core.ts
profilePicture: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
  .middleware(async ({ req }) => {
    const session = await getServerSession();
    if (!session?.user?.id) throw new Error('Unauthorized');
    return { userId: session.user.id };
  })
  .onUploadComplete(async ({ metadata, file }) => {
    // Save to database
    await db.user.update({
      where: { id: metadata.userId },
      data: { avatarUrl: file.url },
    });

    // This return value is sent back to the client's onClientUploadComplete
    return { avatarUrl: file.url };
  }),

The data you return from onUploadComplete is forwarded to the client's onClientUploadComplete callback in serverData. So res[0].serverData.avatarUrl is available on the client after the upload completes. No extra API call needed. That's a nice detail.

One common gotcha: the onUploadComplete callback runs asynchronously and might be called even if the user has navigated away. Don't depend on a client-side state update happening here — write to your DB and let the client re-fetch or use the serverData return value.

Worth noting: if you're using Next.js Server Actions, you can trigger a revalidatePath call inside onUploadComplete to automatically refresh your UI after the upload. That combo is very clean.

Styling UploadThing Components with Tailwind and Design Systems

The pre-built UploadThing components accept a className prop and a config prop for some styling, but if you want real control, you're better off using CSS custom properties or overriding the default stylesheet. UploadThing v6 ships its own CSS that you import separately — without it, the components have no styles at all, which actually makes overriding easier.

For a glassmorphic upload zone that fits with the kind of UI you'd build using Empire UI's glassmorphism components, here's a pattern that works cleanly: ``tsx <UploadDropzone endpoint="documentUpload" appearance={{ container: 'rounded-2xl border border-white/20 bg-white/10 backdrop-blur-md', label: 'text-white/80 font-medium', allowedContent: 'text-white/50 text-xs', button: 'bg-violet-500 hover:bg-violet-600 text-white rounded-lg px-6 py-2 font-medium', uploadIcon: 'text-white/60', }} onClientUploadComplete={(res) => console.log(res)} onUploadError={console.error} /> ``

The appearance prop added in v6 is the right way to do this now — avoid targeting internal class names with arbitrary CSS selectors, they change between minor versions and you'll have a bad time. Stick to the official API.

In practice, for production UI you're usually better off with the useUploadThing hook and your own component anyway. You get pixel-perfect control and you're not fighting someone else's DOM structure. The pre-built components are great for prototypes and internal tools where you'd otherwise spend 45 minutes styling something that nobody outside your company will ever see.

FAQ

Is UploadThing free?

There's a generous free tier — 2GB storage and 2GB bandwidth per month as of 2026. Paid plans start at $10/month for higher limits. For most side projects and early-stage apps, the free tier covers you.

Can I use UploadThing with the Next.js Pages Router?

Yes, but the setup is slightly different. You create the route handler in pages/api/uploadthing.ts using createNextPageApiHandler instead of createRouteHandler. The client-side code is identical.

Where are my files actually stored?

UploadThing stores files on their infrastructure, which is backed by S3-compatible object storage. You get a public CDN URL back after each upload. You don't control the bucket, which is the whole point — but it also means you can't apply custom lifecycle policies.

How do I delete files after upload?

Use the UTApi server-side class: import { UTApi } from 'uploadthing/server'; const utapi = new UTApi(); await utapi.deleteFiles(['fileKey']);. The file key is the last segment of the file URL, or you can store it separately when you save the URL to your database.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Vercel Edge Config: Feature Flags, A/B Tests Without RedeployBetter Auth Guide 2026: Sessions, Social, 2FA in Next.jsUploadThing vs Direct S3: File Upload Tradeoffs in Next.jsFile-Based Routing in React: Next.js App Router vs TanStack Router