Setting up file uploads directly with S3 in Next.js means: presigned URLs, CORS config, IAM roles, auth middleware, file validation, progress tracking, and 200+ lines of boilerplate before you even store the file URL in your database.
UploadThing does all of that in ~20 lines. The file goes directly from the browser to their CDN. Your server only runs auth and metadata storage. Here's the complete setup.
How UploadThing works
The architecture is simple:
- Browser requests an upload URL from your
/api/uploadthingroute - Your
.middleware()runs — auth check, returns metadata - If auth passes, file goes directly browser → UploadThing CDN (your server never touches the bytes)
-
.onUploadComplete()fires — you save the URL and metadata to your database
Your server bandwidth usage: zero. Your server memory usage: zero. No matter how large the file is.
Setup
Install
npm install uploadthing @uploadthing/react
Environment variables
UPLOADTHING_SECRET=sk_live_... # from uploadthing.com dashboard
UPLOADTHING_APP_ID=...
The File Router
This is the core of UploadThing. One file defines everything: what file types are allowed, who's authenticated, and what happens after upload.
// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { auth } from '@clerk/nextjs/server';
import { db } from '@/lib/db';
import { files } from '@/lib/schema';
const f = createUploadthing();
export const ourFileRouter = {
// Profile picture uploader
profilePicture: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
.middleware(async ({ req }) => {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
return { userId };
})
.onUploadComplete(async ({ metadata, file }) => {
await db.insert(files).values({
userId: metadata.userId,
fileUrl: file.url,
fileKey: file.key, // ← store this, you need it for deletion
fileName: file.name,
fileSize: file.size,
});
return { url: file.url };
}),
// Document uploader with multiple file types
documentUploader: f({
pdf: { maxFileSize: '16MB' },
'application/msword': { maxFileSize: '8MB' },
})
.middleware(async () => {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
return { userId };
})
.onUploadComplete(async ({ metadata, file }) => {
console.log('Uploaded:', file.url, 'for user:', metadata.userId);
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;
The route handler
// app/api/uploadthing/route.ts
import { createRouteHandler } from 'uploadthing/next';
import { ourFileRouter } from './core';
export const { GET, POST } = createRouteHandler({ router: ourFileRouter });
That's it for the server side.
Upload components
UploadThing provides two ready-made React components. Generate them once:
// lib/uploadthing.ts
import { generateReactHelpers } from '@uploadthing/react';
import type { OurFileRouter } from '@/app/api/uploadthing/core';
export const { useUploadThing, UploadButton, UploadDropzone } =
generateReactHelpers<OurFileRouter>();
UploadButton — click to select
'use client';
import { UploadButton } from '@/lib/uploadthing';
export function ProfilePictureUpload() {
return (
<UploadButton
endpoint="profilePicture"
onClientUploadComplete={(res) => {
console.log('Uploaded:', res[0].url);
// update your UI
}}
onUploadError={(error) => {
alert(`Upload failed: ${error.message}`);
}}
/>
);
}
UploadDropzone — drag & drop
'use client';
import { UploadDropzone } from '@/lib/uploadthing';
export function DocumentDropzone() {
return (
<UploadDropzone
endpoint="documentUploader"
onClientUploadComplete={(res) => {
console.log('Files:', res.map(f => f.url));
}}
onUploadError={(error) => {
alert(`Error: ${error.message}`);
}}
/>
);
}
Both show a built-in progress bar, file name, and size. Fully customizable with className.
Deleting files — the part everyone forgets
Deleting the database row does NOT delete the file from storage. You end up with orphaned files that accumulate and cost you.
// app/actions/files.ts
'use server';
import { UTApi } from 'uploadthing/server';
import { db } from '@/lib/db';
import { files } from '@/lib/schema';
import { eq } from 'drizzle-orm';
const utapi = new UTApi();
export async function deleteFile(fileId: string) {
const file = await db.query.files.findFirst({ where: eq(files.id, fileId) });
if (!file) throw new Error('File not found');
// Delete from UploadThing storage FIRST
await utapi.deleteFiles(file.fileKey);
// Then delete from your database
await db.delete(files).where(eq(files.id, fileId));
}
Always store fileKey (from file.key in onUploadComplete) alongside fileUrl. You need both.
What to store in the database
// Your files table schema (Drizzle)
export const files = pgTable('files', {
id: uuid('id').defaultRandom().primaryKey(),
userId: text('user_id').notNull(),
fileUrl: text('file_url').notNull(), // for displaying the file
fileKey: text('file_key').notNull(), // for deleting the file
fileName: text('file_name').notNull(),
fileSize: integer('file_size').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
Production checklist
- [ ]
UPLOADTHING_SECRETis server-side only — never expose it to the client - [ ] Auth check in
.middleware()— throw to reject the upload - [ ] Store both
fileUrlANDfileKeyin your database - [ ] Delete from UploadThing + DB atomically — use a Server Action
- [ ] Set
maxFileSizeandmaxFileCounton every endpoint - [ ] Handle
onUploadErroron the client — never swallow errors silently - [ ] Use
generateReactHelpersfor type-safe endpoint names
Free tier
2 GB storage · 2 GB bandwidth/month · No credit card required. Generous enough to ship your MVP and validate the product before paying anything.
Full guide: stacknotice.com/blog/uploadthing-nextjs-complete-guide-2026
Top comments (0)