← Back to blogs

Uploadthing is the file upload I stopped building myself

By AntonioGitHub ↗LinkedIn ↗
Next.jsWeb AppFull Stack

File uploads are one of those things that seem simple until you're debugging S3 presigned URLs at midnight. Uploadthing is the abstraction I didn't know I needed.

File uploads are one of those things that seem simple until you're three hours into debugging S3 presigned URLs and you've forgotten what you were actually building. Rolling your own upload pipeline in Next.js means wiring up an S3 bucket, configuring IAM policies, writing a server endpoint to generate presigned URLs, adding client-side logic to PUT the file directly to S3, tracking upload progress, validating file types on both ends, and then storing the resulting URL in your database. None of it is particularly hard. All of it is work that has nothing to do with your actual product.

Uploadthing is an abstraction over all of that. I reached for it while building the Recipe App — a Next.js PWA where users upload photos for their recipes. Here's what using it actually looks like.

What Uploadthing actually does

The core concept is a file router. You define it on the server: which upload routes exist, what file types they accept, maximum sizes, and an auth middleware that runs before the upload is allowed. The client gets a typed hook — useUploadThing — that gives you progress callbacks, error handling, and completion events. Under the hood, Uploadthing handles presigned URLs, chunking, retries, and delivery via their CDN. You get back a URL to store.

The key pieces: a file router definition with full type safety, an auth callback on each route for per-user authorization, and the client hook for progress and completion. Files land on Uploadthing's CDN and you get back a URL to store in your database. A minimal file router looks like this:

typescript
import { createUploadthing, type FileRouter } from "uploadthing/next";
const f = createUploadthing();

export const ourFileRouter = {
  imageUploader: f({ image: { maxFileSize: "4MB" } })
    .middleware(async ({ req }) => {
      const user = await auth(); // Clerk or your auth
      if (!user) throw new UploadThingError("Unauthorized");
      return { userId: user.id };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      await db.insert(images).values({ url: file.url, userId: metadata.userId });
    }),
} satisfies FileRouter;

The file router is type-safe end-to-end. The metadata you return from .middleware() is typed and available in .onUploadComplete(). The client hook is generated from your router type, so you get autocomplete on which routes exist and what options they accept.

The security footgun

Client-side validation — checking file type and size before upload — is UX, not security. It prevents accidental bad uploads and gives the user fast feedback. It does not stop someone from sending a crafted request directly to your endpoint.

Uploadthing validates file type and size on the server in the middleware, but the most important security boundary is the auth check in .middleware(). If you skip it, anyone with your endpoint URL can upload files to your account. Don't skip it.

One more thing worth knowing: the URL Uploadthing returns is public by default. There's no built-in private file access with signed URLs. If you need files accessible only to specific users, Uploadthing doesn't solve that for you. For the Recipe App — where recipe images are public — this was fine. For private user documents, it would be a problem.

Compared to rolling your own on S3

The DIY path isn't unreasonable. You'd pull in @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner, write a POST endpoint to call getSignedUrl and return a presigned PUT URL, have the client PUT the file directly to S3, then make a second call back to your API to confirm the upload and store the URL. Add bucket CORS configuration and an IAM policy with PutObject permission.

Total: around 100 lines of code across three files, plus IAM configuration. Uploadthing is around 40 lines total. The S3 version gives you full control over infrastructure, costs, and access patterns. The Uploadthing version gives you 30 minutes back.

The tradeoff: with Uploadthing, your files live on their CDN, not infrastructure you control. Their free tier is generous for a side project and pricing scales reasonably, but you're adding a vendor dependency. If Uploadthing stops existing or changes pricing, you're migrating. For a startup or side project, that's an acceptable risk. For enterprise with strict data residency requirements, it probably isn't.

When I'd skip it

If you already have S3 in your stack and the devops to manage it, Uploadthing adds a new vendor dependency for no meaningful gain. If you need private files with signed access URLs, you'll want more control than Uploadthing provides. If file storage cost is a concern at scale, owning the infrastructure makes more sense. If compliance or data residency requirements apply to uploaded files, Uploadthing's hosted CDN is a non-starter.

It's a strong default for new projects where you want file uploads working in an afternoon. It's a harder sell if you're already set up on S3 or have constraints that require infrastructure control.

Did it solve the problem?

Yes. Recipe photos upload, land on the CDN, and the URL gets stored in the database — exactly what was needed. The whole integration took about an hour including reading the docs. I'd reach for Uploadthing again on any new Next.js project where file uploads are a feature, not the product.

The Recipe App this was built for is on GitHub if you want to see the full integration in context.

Related posts