Skip to main content

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.


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

UploadThingDirect S3
Setup time1-2 hoursHalf day
Monthly cost$10/mo (10GB)~$0.023/GB
Resumable uploadsManual
React componentsBuild your own
Vendor lock-inMediumNone
Bandwidth costIncludedEgress fees

Find boilerplates with file upload built-in on StarterPick.

Check out this boilerplate

View ShipFast on StarterPick →

Comments