EmpireUI
Get Pro
← Blog8 min read#uploadthing#s3#file upload

UploadThing vs Direct S3: File Upload Tradeoffs in Next.js

UploadThing handles auth, CDN, and routing for you — direct S3 gives you full control. Here's the real tradeoff for Next.js apps in 2026.

developer reviewing file upload code on a dark terminal screen

Why File Upload Is Still a Pain in 2026

You'd think this would be solved by now. File upload is one of those features every app needs — profile photos, document attachments, video clips — and yet developers still spend hours wiring it up from scratch. The reason isn't the browser API. It's everything around it: authentication, file size limits, content-type validation, CDN distribution, and keeping your Next.js server route from timing out on a 50 MB PDF.

Two approaches dominate the Next.js ecosystem right now. First, UploadThing — a developer-focused SaaS that wraps the whole upload pipeline behind a typed SDK. Second, direct S3 uploads using presigned URLs, where the client uploads straight to your S3-compatible bucket without touching your server. Both work. Neither is universally better. The right choice depends entirely on how much control you want vs. how fast you need to ship.

Honestly, the conversation has shifted a lot since the App Router stabilized in Next.js 13.4. Server Components and Route Handlers changed which patterns are even practical. This article compares both approaches head-to-head so you can make an informed call for your specific project.

Quick aside: if you're building a SaaS with a polished frontend, file upload UX matters too — drag zones, progress bars, preview thumbnails. The Empire UI component library has several patterns worth pulling from before you write any custom upload UI.

UploadThing: What You Actually Get

UploadThing (currently v7.x) is a file upload service built specifically for the Next.js/React ecosystem. You define an "upload router" — a typed configuration object that declares which file types and max sizes are allowed for each endpoint — and the SDK handles the rest. Authentication plugs in via a middleware function. Files go to UploadThing's CDN. You get a permanent URL back.

Setup is genuinely fast. Install two packages, create one Route Handler at /api/uploadthing, define your router, and drop the <UploadButton> component into your page. The whole thing takes about 20 minutes if you've done it once before. That 20-minute time-to-working is the core value proposition.

// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { auth } from '@/lib/auth'; // your auth provider

const f = createUploadthing();

export const ourFileRouter = {
  profileImage: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
    .middleware(async () => {
      const session = await auth();
      if (!session) throw new Error('Unauthorized');
      return { userId: session.user.id };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      // file.url is already a CDN URL
      await db.user.update({
        where: { id: metadata.userId },
        data: { avatarUrl: file.url },
      });
    }),
} satisfies FileRouter;

export type OurFileRouter = typeof ourFileRouter;

The typed router is the killer feature here. Your client-side code imports OurFileRouter and gets full type safety on which endpoints exist, what they accept, and what metadata flows through. Refactors don't silently break things. Worth noting: UploadThing's free tier gives you 2 GB storage and 2 GB bandwidth per month — fine for prototypes, limiting for production.

The downside is real: you're routing all your metadata through their servers, paying their pricing above the free tier, and you can't self-host. If your company has data residency requirements — HIPAA, GDPR strict-mode, government contracts — UploadThing probably isn't on the table at all.

Direct S3 With Presigned URLs: The Unfiltered Version

The presigned URL pattern is dead simple in concept. Your Next.js Route Handler calls the AWS SDK, generates a short-lived signed URL for a specific S3 key, returns it to the client, and the client uploads directly to S3. Your server never sees the file bytes. No timeouts. No memory pressure. Scales to 5 GB files with the same code.

// app/api/upload-url/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { auth } from '@/lib/auth';
import { nanoid } from 'nanoid';

const s3 = new S3Client({ region: 'us-east-1' });

export async function POST(request: Request) {
  const session = await auth();
  if (!session) return new Response('Unauthorized', { status: 401 });

  const { filename, contentType } = await request.json();

  // Validate content type server-side — never trust the client
  const allowed = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
  if (!allowed.includes(contentType)) {
    return new Response('File type not allowed', { status: 400 });
  }

  const key = `uploads/${session.user.id}/${nanoid()}-${filename}`;

  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.AWS_BUCKET_NAME!,
      Key: key,
      ContentType: contentType,
      // Enforce max size at the S3 layer with a content-length-range condition
    }),
    { expiresIn: 60 } // 60 seconds — tight window on purpose
  );

  return Response.json({ url, key });
}

On the client, you fetch that URL, then do a plain fetch(url, { method: 'PUT', body: file }) directly against S3. Zero bytes hit your Next.js server. The S3 pricing model is also much cheaper at scale — you pay per GB stored and per GB transferred, not a per-upload fee on top.

The real complexity is everything you now own: S3 CORS configuration (and yes, you will get that wrong the first time), IAM policy scoping so the presigned URL can only write to the correct prefix, CloudFront in front of S3 for CDN distribution, content-type validation that can't be bypassed, and virus scanning if you're accepting arbitrary files. None of this is hard, but each piece is a config you have to write, test, and maintain. In practice, this is where teams underestimate the real cost.

One more thing — multipart uploads for files over 100 MB require a different SDK flow (CreateMultipartUpload, UploadPart, CompleteMultipartUpload) which UploadThing handles transparently. With direct S3 you're writing that state machine yourself.

Performance and Cost at Scale

For small files under 10 MB, the latency difference between the two approaches is negligible from the user's perspective. Both get the file to a CDN fast. The divergence shows up at scale and at file size.

With direct S3, there's no intermediary. The client connects to an S3 endpoint (or your CloudFront distribution in front of it) directly. Upload throughput is limited only by the client's network and S3's capacity — which is effectively unlimited. UploadThing proxies metadata and coordinates the upload through their infrastructure, which adds a coordination round-trip.

Look, the pricing math is stark. UploadThing's Pro plan (as of 2026) runs $0.09/GB stored and $0.12/GB egress above the free tier. AWS S3 standard storage is $0.023/GB with egress to the internet at $0.09/GB — and if you pair it with CloudFront, the egress cost drops to $0.0085/GB for the first 10 TB. If you're uploading and serving a serious volume, direct S3 wins on cost. If you're uploading a few hundred files a month on an internal tool, UploadThing's developer ergonomics are worth the premium.

There's also cold-start to consider. UploadThing's Route Handler runs on your Next.js server — on Vercel that means serverless functions, and a cold start on upload initiation adds perceived latency. Direct presigned URL generation is a single lightweight SDK call, so it's fast even from a cold function. Worth benchmarking if you're on a serverless host.

Security Surface Area: Where Each Approach Gets Tricky

Both approaches are secure if you implement them correctly. Both have specific failure modes that are easy to introduce accidentally.

With UploadThing, the main risk is overly permissive middleware. If your .middleware() function doesn't properly validate the session, unauthenticated users can upload to your UploadThing account. The type system won't save you here — auth logic is runtime. Also, UploadThing generates the storage key, so you don't control the directory structure or naming convention. That matters if you need hierarchical access control (e.g., multi-tenant apps where tenant A should never read tenant B's files).

Direct S3 has a different problem: the IAM policy on the role your server uses to call getSignedUrl needs to be tightly scoped. If you grant s3:PutObject on arn:aws:s3:::my-bucket/*, a malicious client could potentially guess valid presigned URLs (they can't — URLs are signed — but the policy allows writes to any key, so if your key generation has a bug, you could overwrite existing objects). Scope the policy to your specific upload prefix: arn:aws:s3:::my-bucket/uploads/*.

// Tight IAM policy for presigned URL generation
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject"],
      "Resource": "arn:aws:s3:::my-bucket/uploads/*",
      "Condition": {
        "StringEquals": {
          "s3:prefix": "uploads/"
        }
      }
    }
  ]
}

Content-type sniffing is another gap in the direct S3 approach. S3 stores whatever Content-Type header the client sends. Validate the MIME type server-side before issuing the presigned URL — never trust the client's declared type. UploadThing does this for you based on your router definition, which is one genuine convenience worth acknowledging.

When to Pick Which

The decision tree is shorter than most articles make it seem.

Pick UploadThing if you're building an MVP or internal tool where time-to-ship matters more than infrastructure ownership, your team is small (1-3 engineers), you don't have data residency requirements, and file volumes are modest. The typed SDK and the zero-config CDN are genuinely good. Don't overthink it.

Pick direct S3 if you need self-hosting or data residency compliance, you're expecting meaningful file volumes where per-GB pricing matters, you want to avoid a vendor in your critical upload path, or you need custom storage structure for multi-tenant access control. Yes, there's more wiring. But once it's done, it's yours.

There's a third option worth naming: S3-compatible self-hosted storage like Cloudflare R2 or MinIO. R2 in particular has zero egress fees and the same presigned URL API as S3 — you literally swap the endpoint URL and credentials, and your AWS SDK code works unchanged. For apps with high download traffic (think asset-heavy SaaS), R2's pricing model is significantly better than AWS S3 egress rates. You can explore the frontend side of this — drag-and-drop zones, progress indicators, image previews — using the UI patterns in Empire UI, where the component library handles the visual layer cleanly without coupling to any backend.

One more thing — whatever you pick, keep the upload UI decoupled from the transport layer. A well-structured useUpload hook that accepts an uploadFn: (file: File) => Promise<string> parameter makes it trivial to swap UploadThing for direct S3 (or vice versa) without touching your UI components. That single abstraction has saved teams hours when requirements change mid-project.

Practical Next.js Integration Patterns

Whichever approach you choose, there are a few integration patterns that make the Next.js side cleaner. First, always store the canonical file URL in your database immediately after upload — not the presigned URL (which expires) and not the raw S3 key. If you're serving through CloudFront, construct the CDN URL at write time: https://cdn.yourapp.com/${key}. This means you can switch CDN providers later without a data migration.

// hooks/useFileUpload.ts — transport-agnostic upload hook
import { useState } from 'react';

type UploadFn = (file: File) => Promise<string>; // resolves to final CDN URL

export function useFileUpload(uploadFn: UploadFn) {
  const [progress, setProgress] = useState(0);
  const [uploading, setUploading] = useState(false);
  const [url, setUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const upload = async (file: File) => {
    setUploading(true);
    setError(null);
    try {
      const resultUrl = await uploadFn(file);
      setUrl(resultUrl);
      setProgress(100);
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Upload failed');
    } finally {
      setUploading(false);
    }
  };

  return { upload, progress, uploading, url, error };
}

For progress tracking with direct S3, use XMLHttpRequest instead of fetch — XHR's upload.onprogress event fires as bytes transfer. Fetch's streaming body upload technically supports progress in modern browsers via ReadableStream, but XHR is still more universally supported and simpler to implement correctly.

If you're pairing file uploads with a rich form UI — multi-step wizards, drag-and-drop zones, live image previews — check out the glassmorphism components on Empire UI for overlay-style upload dialogs, or the box shadow generator for styling drop zones with realistic depth. The visual polish on upload interactions matters more than developers usually expect, especially for B2B products where file upload is a core workflow.

That said, the backend plumbing we've covered here is the part that actually breaks in production. Get the auth middleware right, scope your IAM policies tight, validate MIME types server-side, and store CDN URLs in your database. Everything else is optimization.

FAQ

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

Yes — UploadThing v6+ has first-class App Router support. You create a Route Handler at app/api/uploadthing/route.ts and export the handlers from the SDK. The <UploadButton> and <UploadDropzone> components are Client Components, so wrap them in 'use client' or import from a client boundary.

Do presigned S3 URLs work from the browser without CORS errors?

Only if you configure S3 CORS correctly. You need to add a CORS rule to your bucket that allows PUT requests from your app's origin. Forgetting Content-Type in the AllowedHeaders list is the single most common cause of silent upload failures — the presigned URL works but the PUT is rejected.

What's the max file size UploadThing supports?

As of 2026, UploadThing supports up to 2 GB per file on paid plans. Free tier caps at 512 MB. For anything larger, direct S3 multipart upload is the more reliable path — there's no practical S3 file size limit below 5 TB.

Is Cloudflare R2 a drop-in replacement for S3 in a presigned URL setup?

Essentially yes. R2 is S3-compatible, so you point the AWS SDK at the R2 endpoint URL, swap your credentials, and presigned URL generation works the same way. The main win is zero egress fees, which makes a meaningful cost difference if you're serving lots of downloads.

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

Read next

Next.js vs Remix in 2026: Which One Should You Use?Vite + React vs Next.js in 2026: Which Scaffold to ChooseUploadThing Guide: File Uploads in Next.js Without the S3 Config PainNext.js App Router vs Pages Router in 2026: Which Should You Use?