How to Add File Upload to Your SaaS Starter (2026)
·StarterPick Team
file-uploaduploadthings3nextjsguide2026
TL;DR
UploadThing is the fastest path to file uploads in Next.js in 2026. It handles the S3 infrastructure, resumable uploads, and provides React components. For more control or existing AWS infrastructure, direct S3 with presigned URLs is the alternative. Setup time: UploadThing takes 1-2 hours; direct S3 takes half a day.
Option 1: UploadThing (Recommended)
npm install uploadthing @uploadthing/react
Server Configuration
// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { getServerSession } from 'next-auth';
const f = createUploadthing();
export const ourFileRouter = {
// Profile picture: image only, 4MB max
profilePicture: f({ image: { maxFileSize: '4MB' } })
.middleware(async () => {
const session = await getServerSession();
if (!session) throw new Error('Unauthorized');
return { userId: session.user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
await prisma.user.update({
where: { id: metadata.userId },
data: { image: file.url },
});
return { url: file.url };
}),
// Document upload: PDF/Word, 16MB max
document: f({
pdf: { maxFileSize: '16MB' },
'application/msword': { maxFileSize: '16MB' },
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { maxFileSize: '16MB' },
})
.middleware(async () => {
const session = await getServerSession();
if (!session) throw new Error('Unauthorized');
return { userId: session.user.id, orgId: session.organization?.id };
})
.onUploadComplete(async ({ metadata, file }) => {
await prisma.document.create({
data: {
userId: metadata.userId,
organizationId: metadata.orgId,
name: file.name,
url: file.url,
size: file.size,
},
});
return { fileId: file.key };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;
// app/api/uploadthing/route.ts
import { createRouteHandler } from 'uploadthing/next';
import { ourFileRouter } from './core';
export const { GET, POST } = createRouteHandler({ router: ourFileRouter });
Client Upload Component
// components/ProfilePictureUpload.tsx
'use client';
import { UploadButton } from '@uploadthing/react';
import type { OurFileRouter } from '@/app/api/uploadthing/core';
import { useRouter } from 'next/navigation';
export function ProfilePictureUpload() {
const router = useRouter();
return (
<UploadButton<OurFileRouter, 'profilePicture'>
endpoint="profilePicture"
onClientUploadComplete={(res) => {
router.refresh(); // Refresh server component to show new image
}}
onUploadError={(error) => {
alert(`Upload failed: ${error.message}`);
}}
appearance={{
button: 'bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg',
allowedContent: 'text-gray-400 text-xs',
}}
/>
);
}
Option 2: Direct S3 with Presigned URLs
For full control over storage, use presigned URLs to upload directly to S3:
// lib/s3.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export async function getUploadUrl(
key: string,
contentType: string
): Promise<string> {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: key,
ContentType: contentType,
});
return getSignedUrl(s3, command, { expiresIn: 600 }); // 10 min expiry
}
export async function getDownloadUrl(key: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: key,
});
return getSignedUrl(s3, command, { expiresIn: 3600 }); // 1 hour
}
// app/api/upload/presign/route.ts
export async function POST(req: Request) {
const session = await getServerSession();
if (!session) return new Response('Unauthorized', { status: 401 });
const { filename, contentType, size } = await req.json();
// Validate
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (size > MAX_SIZE) return new Response('File too large', { status: 400 });
if (!ALLOWED_TYPES.includes(contentType)) return new Response('Invalid type', { status: 400 });
// Generate unique key
const ext = filename.split('.').pop();
const key = `uploads/${session.user.id}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const uploadUrl = await getUploadUrl(key, contentType);
return Response.json({ uploadUrl, key });
}
// Client-side: upload to S3 directly
async function uploadFile(file: File) {
// 1. Get presigned URL from your API
const { uploadUrl, key } = await fetch('/api/upload/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
}).then(r => r.json());
// 2. Upload directly to S3 (no server bandwidth)
await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
// 3. Store the key in your DB
await fetch('/api/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, name: file.name }),
});
return key;
}
Database Model
model File {
id String @id @default(cuid())
userId String
organizationId String?
name String
url String // UploadThing URL or S3 key
size Int // Bytes
mimeType String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
organization Organization? @relation(fields: [organizationId], references: [id])
@@index([organizationId])
@@index([userId])
}
File Display with Signed URLs
// components/FileList.tsx
export async function FileList({ orgId }: { orgId: string }) {
const files = await prisma.file.findMany({
where: { organizationId: orgId },
orderBy: { createdAt: 'desc' },
});
return (
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
{files.map(file => (
<li key={file.id} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<FileIcon className="w-5 h-5 text-gray-400" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-gray-400">{formatBytes(file.size)}</p>
</div>
</div>
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 text-sm hover:text-indigo-700"
>
Download
</a>
</li>
))}
</ul>
);
}
Comparison: UploadThing vs Direct S3
| UploadThing | Direct S3 | |
|---|---|---|
| Setup time | 1-2 hours | Half day |
| Monthly cost | $10/mo (10GB) | ~$0.023/GB |
| Resumable uploads | ✅ | Manual |
| React components | ✅ | Build your own |
| Vendor lock-in | Medium | None |
| Bandwidth cost | Included | Egress fees |
Find boilerplates with file upload built-in on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →