Every SaaS app eventually needs file uploads — profile photos, document attachments, image galleries. The naive solution is to send the file to your Next.js API route, which proxies it to S3. This works until your Vercel function times out on a 10MB upload, or you discover you're paying twice for the bandwidth.
UploadThing solves this by handling the upload directly from the browser to storage, using a server-side file router for configuration and auth. This guide covers the full production setup: file router with Clerk auth, storing file metadata with Drizzle, drag & drop UI, and error handling.
Why UploadThing over direct S3
| Direct S3 | UploadThing | |
|---|---|---|
| Setup | IAM roles, presigned URLs, CORS config | 15 minutes |
| TypeScript | Manual types | Full type-safe file router |
| Auth | Manual per upload | Middleware in file router |
| Cost | S3 bandwidth ($0.09/GB) | Free: 2 GB storage + 2 GB bandwidth/month |
| File validation | Manual | Configured in router (type, size) |
| Resumable uploads | DIY | Built-in for large files |
Direct S3 is the right choice when you need fine-grained control, custom storage backends, or very high upload volumes. For most SaaS apps, UploadThing is the faster and cheaper path.
Installation and environment
npm install uploadthing @uploadthing/reactGet your API keys from uploadthing.com, create a project, and add them to .env.local:
UPLOADTHING_TOKEN=eyJhbGci...That's the only environment variable you need. UploadThing v7+ consolidates app ID and secret into a single token.
The File Router — the core concept
The File Router is where you define what your app accepts: file types, size limits, and auth logic. Every upload goes through a route defined here.
// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { auth } from '@clerk/nextjs/server';
const f = createUploadthing();
export const ourFileRouter = {
// Profile photo — one image, max 4MB
profilePhoto: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
.middleware(async () => {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
// Whatever you return here is available in onUploadComplete
return { userId };
})
.onUploadComplete(async ({ metadata, file }) => {
// Called server-side after upload finishes
await db.update(users)
.set({ avatarUrl: file.url })
.where(eq(users.clerkId, metadata.userId));
return { url: file.url };
}),
// Document attachments — multiple PDFs, max 16MB each
documentAttachment: f({
pdf: { maxFileSize: '16MB', maxFileCount: 10 },
'application/msword': { maxFileSize: '16MB', maxFileCount: 10 },
})
.middleware(async ({ req }) => {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
// You can fetch from DB in middleware too
const user = await db.query.users.findFirst({
where: eq(users.clerkId, userId),
});
if (!user) throw new Error('User not found');
return { userId, organizationId: user.organizationId };
})
.onUploadComplete(async ({ metadata, file }) => {
// Save file metadata to your database
await db.insert(attachments).values({
name: file.name,
url: file.url,
size: file.size,
type: file.type,
organizationId: metadata.organizationId,
uploadedById: metadata.userId,
});
return { name: file.name, url: file.url };
}),
// General image uploads (posts, content)
contentImage: f({ image: { maxFileSize: '8MB', maxFileCount: 4 } })
.middleware(async () => {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
return { userId };
})
.onUploadComplete(async ({ metadata, file }) => {
return { url: file.url };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;The object returned from onUploadComplete is sent to the onClientUploadComplete callback in your upload component. Use this to pass the file URL back after the server has saved it.
Route handler
// app/api/uploadthing/route.ts
import { createRouteHandler } from 'uploadthing/next';
import { ourFileRouter } from './core';
export const { GET, POST } = createRouteHandler({ router: ourFileRouter });Database schema for file metadata
If you're storing file metadata in Drizzle:
// src/db/schema.ts
import { pgTable, text, integer, timestamp, uuid } from 'drizzle-orm/pg-core';
export const attachments = pgTable('attachments', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
url: text('url').notNull(),
size: integer('size').notNull(), // bytes
type: text('type').notNull(),
organizationId: uuid('organization_id').references(() => organizations.id).notNull(),
uploadedById: text('uploaded_by_id').notNull(), // Clerk user ID
createdAt: timestamp('created_at').defaultNow().notNull(),
});Upload components
UploadThing provides two ready-made React components. First, generate typed wrappers from your file router:
// utils/uploadthing.ts
import { generateUploadButton, generateUploadDropzone } from '@uploadthing/react';
import type { OurFileRouter } from '@/app/api/uploadthing/core';
export const UploadButton = generateUploadButton<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();Profile photo with UploadButton
// components/ProfilePhotoUpload.tsx
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { UploadButton } from '@/utils/uploadthing';
interface Props {
currentAvatarUrl: string | null;
onUploadComplete?: (url: string) => void;
}
export function ProfilePhotoUpload({ currentAvatarUrl, onUploadComplete }: Props) {
const [avatarUrl, setAvatarUrl] = useState(currentAvatarUrl);
return (
<div className="flex flex-col items-center gap-4">
{avatarUrl && (
<Image
src={avatarUrl}
alt="Profile photo"
width={80}
height={80}
className="rounded-full object-cover"
/>
)}
<UploadButton
endpoint="profilePhoto"
onClientUploadComplete={(res) => {
const url = res[0]?.url;
if (url) {
setAvatarUrl(url);
onUploadComplete?.(url);
}
}}
onUploadError={(error) => {
console.error('Upload failed:', error.message);
alert(`Upload failed: ${error.message}`);
}}
appearance={{
button: 'bg-orange-500 text-white rounded-lg px-4 py-2 text-sm font-medium',
allowedContent: 'text-gray-500 text-xs',
}}
/>
</div>
);
}Document upload with drag & drop
// components/DocumentUpload.tsx
'use client';
import { useState } from 'react';
import { UploadDropzone } from '@/utils/uploadthing';
interface UploadedFile {
name: string;
url: string;
}
export function DocumentUpload() {
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const [isUploading, setIsUploading] = useState(false);
return (
<div className="space-y-4">
<UploadDropzone
endpoint="documentAttachment"
onUploadBegin={() => setIsUploading(true)}
onClientUploadComplete={(res) => {
setIsUploading(false);
const newFiles = res.map(f => ({ name: f.name, url: f.url }));
setUploadedFiles(prev => [...prev, ...newFiles]);
}}
onUploadError={(error) => {
setIsUploading(false);
console.error('Upload failed:', error.message);
}}
appearance={{
container: 'border-2 border-dashed border-gray-300 rounded-xl p-8',
label: 'text-gray-600 font-medium',
allowedContent: 'text-gray-400 text-sm',
uploadIcon: 'text-gray-400',
button: 'bg-blue-600 text-white rounded-lg',
}}
content={{
label: 'Drag & drop files here or click to browse',
allowedContent: 'PDF, Word — up to 16MB per file, 10 files max',
}}
/>
{uploadedFiles.length > 0 && (
<ul className="space-y-2">
{uploadedFiles.map(file => (
<li key={file.url} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
<span className="text-sm font-medium">{file.name}</span>
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="ml-auto text-blue-600 text-sm"
>
View
</a>
</li>
))}
</ul>
)}
</div>
);
}Deleting files
UploadThing stores files on its CDN — you need to delete them there when a user removes a file from your app.
// app/actions/files.ts
'use server';
import { UTApi } from 'uploadthing/server';
import { auth } from '@clerk/nextjs/server';
import { db } from '@/db';
import { attachments } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
const utapi = new UTApi();
export async function deleteAttachment(attachmentId: string) {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
// Find the file — ensure the user owns it
const attachment = await db.query.attachments.findFirst({
where: and(
eq(attachments.id, attachmentId),
eq(attachments.uploadedById, userId)
),
});
if (!attachment) throw new Error('File not found');
// Extract the UploadThing file key from the URL
// UT URLs look like: https://utfs.io/f/abc123def456
const fileKey = attachment.url.split('/f/')[1];
// Delete from UploadThing storage
await utapi.deleteFiles(fileKey);
// Delete from your database
await db.delete(attachments).where(eq(attachments.id, attachmentId));
}Handling errors
The most common errors and what they mean:
FileSizeMismatch — the file is larger than maxFileSize in your router. Either increase the limit or validate client-side before upload:
onUploadError={(error) => {
if (error.code === 'FileSizeMismatch') {
alert('File too large. Maximum size is 16MB.');
} else if (error.code === 'InvalidFileType') {
alert('Invalid file type. Please upload a PDF or Word document.');
} else {
alert(`Upload failed: ${error.message}`);
}
}}Unauthorized (thrown from middleware) — your auth check failed. Make sure Clerk middleware is configured in middleware.ts and the route is protected.
InternalServerError from onUploadComplete — the file uploaded successfully but your server-side handler threw. The file exists in UploadThing storage but isn't saved in your DB. Log this aggressively — you may have orphaned files.
.onUploadComplete(async ({ metadata, file }) => {
try {
await db.insert(attachments).values({ ... });
} catch (error) {
// Log but don't re-throw — the upload already succeeded
// Consider a background job to clean up orphaned UT files
console.error('[uploadthing] Failed to save file metadata:', {
fileKey: file.key,
userId: metadata.userId,
error,
});
throw error; // Will show as error on client
}
})File type reference
Common MIME types for your file router:
f({
// Images
image: { maxFileSize: '4MB' }, // jpg, png, gif, webp, etc.
'image/svg+xml': { maxFileSize: '1MB' },
// Documents
pdf: { maxFileSize: '16MB' },
'application/msword': { maxFileSize: '16MB' }, // .doc
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { maxFileSize: '16MB' }, // .docx
// Video
video: { maxFileSize: '256MB' },
// Audio
audio: { maxFileSize: '64MB' },
// Catch-all (any type, not recommended for production)
blob: { maxFileSize: '32MB' },
})Production checklist
-
UPLOADTHING_TOKENin environment variables — Vercel dashboard, not.envcommitted to git - Auth in every middleware — never skip the auth check in
.middleware() -
onUploadCompleteerrors logged — file uploads succeed even when your DB save fails; log these - Delete files when removing from DB — use
UTApi.deleteFiles()to avoid orphaned files in storage - Client-side file type validation — don't rely solely on the file router; validate before upload for better UX
- Test with large files — UploadThing handles multipart uploads automatically, but test your
onUploadCompletewith files near the size limit - Monitor UploadThing dashboard — track storage and bandwidth usage against your plan limits
Summary
UploadThing's pattern is simple once you understand it: define what you accept in the file router (type, size, auth), let the client upload directly to storage, then run onUploadComplete on the server to save metadata. The auth middleware pattern means you write auth logic once per upload type, not per request.
The two things that matter most for production: always validate auth in middleware, and always handle onUploadComplete failures so you don't end up with files in storage that aren't in your database.
Related: Drizzle ORM Complete Guide 2026 — storing file metadata with full type safety · Clerk + Next.js Authentication Guide — the auth layer that protects your upload routes · React Server Actions Complete Guide — the pattern behind onUploadComplete