js

Secure File Uploads in Next.js with Uploadthing, Prisma, and NextAuth

Build secure file uploads in Next.js using Uploadthing, Prisma, and NextAuth to prevent orphaned files, enforce access, and scale cleanly.

Secure File Uploads in Next.js with Uploadthing, Prisma, and NextAuth

I still remember the day my team’s file server filled up with ghost images. Users had uploaded profile pictures, then changed them. The old files stayed on disk, orphaned. Our access control was a mess—anyone could guess a file URL and see someone else’s private document. That’s when I decided to build a proper file pipeline: type‑safe, database‑tracked, and serverless‑friendly. If you’ve ever dealt with missing files, security gaps, or storage bloat, you’ll appreciate what follows.

The core idea is simple: every upload goes through a validated endpoint, metadata is stored in a relational database, and the file lives on a CDN that you can control. But the devil is in the details. Let’s walk through a production‑ready setup using Uploadthing, Next.js App Router, Prisma, and NextAuth. I’ll show you exactly what I built for my current project.

Before we dive into code, consider a common question: Why not just save files to the public folder? Because in a serverless environment, disk is ephemeral. You lose files on every deployment. And even on a VPS, managing storage quotas, file type enforcement, and audit trails from scratch is a recipe for bugs. Uploadthing gives you a simple key‑based API, automatic CDN delivery, and resumable uploads out of the box. But I still need to track each file in my database to know who owns it, when it was uploaded, and whether it’s still active.

So I start with a Prisma schema that captures the lifecycle of every file. I use an Upload model with fields for the original filename, Uploadthing file key, the public URL, file size, MIME type, status, and a relation to the user. A status enum lets me mark files as ACTIVE, DELETED, or REPLACED. This way I can soft‑delete files and later run a cleanup job to actually remove them from storage.

model Upload {
  id        String   @id @default(cuid())
  fileName  String
  fileKey   String   @unique
  fileUrl   String
  fileSize  Int
  mimeType  String
  category  String   // AVATAR, DOCUMENT, etc.
  status    String   @default("ACTIVE")
  user      User     @relation(fields: [userId], references: [id])
  userId    String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Notice the unique constraint on fileKey—this prevents duplicate references to the same file. When a user uploads a new avatar, I set the old one’s status to REPLACED and insert the new record. Later I can delete the old file key from Uploadthing.

Now, how do you enforce that only authenticated users can upload? Uploadthing’s core allows you to run middleware before each upload. I wire in NextAuth’s session and fetch the user from the database. If the session is missing, the middleware throws an error, and the upload never starts.

import { createUploadthing } from "uploadthing/next";
import { getServerSession } from "next-auth";

const f = createUploadthing();

const authMiddleware = async () => {
  const session = await getServerSession(authOptions);
  if (!session?.user?.email) throw new Error("Not authenticated");
  return { userId: session.user.id };
};

Then I define my upload endpoints, such as avatarUploader. Here I restrict to images only, max 2 MB, single file. The .onUploadComplete hook is where I persist the record. Because I know the file is safely on the CDN at that point, I can update the database with a transaction.

avatarUploader: f({ image: { maxFileSize: "2MB", maxFileCount: 1 } })
  .middleware(() => authMiddleware())
  .onUploadComplete(async ({ metadata, file }) => {
    await prisma.$transaction(async (tx) => {
      await tx.upload.updateMany({
        where: { userId: metadata.userId, category: "AVATAR", status: "ACTIVE" },
        data: { status: "REPLACED" },
      });
      await tx.upload.create({
        data: {
          fileName: file.name,
          fileKey: file.key,
          fileUrl: file.url,
          fileSize: file.size,
          mimeType: file.type ?? "image/png",
          category: "AVATAR",
          userId: metadata.userId,
        },
      });
    });
  }),

The transaction is critical—it ensures that if the database insert fails, the old avatar stays active. No inconsistent state.

On the client side, I use Uploadthing’s @uploadthing/react component. It’s a drop‑in that handles progress bars, file validation, and the actual request. I pass my server endpoint name.

import { UploadButton } from "@uploadthing/react";
import { OurFileRouter } from "@/app/api/uploadthing/core";

export function AvatarUpload() {
  return (
    <UploadButton<OurFileRouter>
      endpoint="avatarUploader"
      onClientUploadComplete={(res) => {
        console.log("Upload completed:", res);
      }}
      onUploadError={(e) => {
        alert("Upload failed: " + e.message);
      }}
    />
  );
}

But what about client‑side validation before the upload even reaches the server? Uploadthing’s endpoint config already limits file types and sizes. I also add Zod on the client to validate any extra metadata I want to attach—like a caption or a category tag. That prevents sending bad data that would be rejected by the server anyway.

Let me pause here and ask: Have you ever uploaded a large file only to get an error 20 seconds later because the type was wrong? Pre‑validating with Zod saves bandwidth and user frustration. I keep a simple schema for each upload type.

import { z } from "zod";

const avatarMetaSchema = z.object({
  caption: z.string().max(100).optional(),
});

// Validate before calling the upload function
// (pseudo‑code—Uploadthing doesn't support custom metadata directly in the same flow,
// but you can attach metadata via the ‘fileRouter’)

Now, how do you handle file deletion when a user deletes their account? I create a background job that queries all ACTIVE uploads for that user, calls Uploadthing’s server API to delete the file by key, then marks the database records as DELETED. The same approach works for cleaning up replaced avatars. You can run this job via a cron trigger or on the Next.js API route when the user is removed.

import { UTApi } from "uploadthing/server";

const utapi = new UTApi();

async function deleteUserFiles(userId: string) {
  const uploads = await prisma.upload.findMany({
    where: { userId, status: "ACTIVE" },
  });

  for (const upload of uploads) {
    await utapi.deleteFiles(upload.fileKey);
  }

  await prisma.upload.updateMany({
    where: { userId, status: "ACTIVE" },
    data: { status: "DELETED" },
  });
}

This approach gives you full visibility into what’s stored and what should be removed. No more orphaned files.

One more thing: access control. Suppose you only want admins to upload certain documents. Your middleware can check the user’s role and throw an error for non‑admins. I often split endpoints into adminDocumentUploader and userDocumentUploader with different rules.

adminDocumentUploader: f({ pdf: { maxFileSize: "64MB" } })
  .middleware(async () => {
    const { userId, userRole } = await authMiddleware();
    if (userRole !== "ADMIN") throw new Error("Forbidden");
    return { userId, userRole };
  })
  // ...

Now, you might wonder: What if I need resumable uploads for very large files? Uploadthing supports that natively with its chunked upload feature. I just set maxFileSize to, say, "2GB" and the SDK handles the rest. My database still records a single upload record after the whole file is assembled.

The beauty of this system is that it separates concerns. Storage is managed by Uploadthing, identity by NextAuth, persistence by Prisma, and validation by Zod. Each piece does one thing well.

Let me end with some practical advice. When you first set this up, test with a tiny file and check that the upload record appears in your database. Then try uploading the same file again—does it correctly replace the old one? What happens if the user closes the browser mid‑upload? Uploadthing’s client will abort, and the incomplete file will be cleaned up automatically by their server after a timeout. But your database will not have a record, so it’s safe.

If you found this article helpful, please like, share, and comment below. I’d love to hear about your own file upload horror stories—or how you solved a similar problem. Your feedback helps me write better content for the next topic.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: Next.js file uploads, Uploadthing, Prisma, NextAuth, secure file storage



Similar Posts
Blog Image
Building High-Performance Real-time Collaborative Applications with Yjs Socket.io and Redis Complete Guide

Learn to build real-time collaborative apps using Yjs, Socket.io & Redis. Master CRDTs, conflict resolution & scaling for hundreds of users. Start now!

Blog Image
Build a Real-time Collaborative Document Editor with Socket.io, Operational Transform, and Redis Complete Guide

Learn to build a real-time collaborative document editor using Socket.io, Operational Transform & Redis. Master conflict resolution, scaling & deployment.

Blog Image
Building Full-Stack TypeScript Apps: Complete Next.js and Prisma Integration Guide for Type-Safe Development

Learn to build type-safe full-stack apps with Next.js and Prisma integration. Master TypeScript database operations, schema management, and end-to-end development.

Blog Image
How to Build End-to-End Encryption in a Node.js Chat App with Signal Protocol

Learn end-to-end encryption in a Node.js chat app using Signal Protocol, libsodium, X3DH, and double ratchet. Build secure messaging now.

Blog Image
Stop Crashing Your Express API: How to Validate Requests with Joi

Learn how to prevent server crashes and simplify your code by validating incoming requests in Express using Joi middleware.

Blog Image
How to Use Worker Threads in Node.js to Prevent Event Loop Blocking

Learn how Worker Threads in Node.js can offload CPU-heavy tasks, keep your API responsive, and boost performance under load.