js

Type-Safe File Uploads in Next.js with Uploadthing, Prisma, and TypeScript

Build secure Next.js file uploads with Uploadthing, Prisma, and TypeScript. Add progress, save metadata, and simplify your upload flow.

Type-Safe File Uploads in Next.js with Uploadthing, Prisma, and TypeScript

I’ve been thinking about file uploads lately because, honestly, most of them still feel like a chore. We’ve built incredible web applications with smooth interfaces, but the moment a user needs to upload a profile picture or a document, we often fall back to clunky, insecure, or complicated methods. I wanted to find a better way, a method that felt safe, simple, and built for the modern web. This led me to a stack that finally makes file uploads feel effortless and robust. What if you could manage uploads with the same type safety you expect from your database queries?

Let’s build something real. We’ll create a system where a user can upload a file, see progress, and have that file’s metadata securely saved to a database, all with full TypeScript confidence from front to back. This approach moves away from the old, patchwork method of presigned URLs and manual API routes.

The first step is understanding the moving parts. We need a place to store files, a way to upload them securely from the browser, a server to process logic, and a database to keep track of everything. Trying to wire this together from scratch is a common source of bugs. Have you ever struggled with CORS policies or file size limits in a DIY solution?

This is where a service like Uploadthing fits in. It acts as the dedicated storage and delivery layer, but crucially, it integrates directly with your Next.js application. You define your upload rules in your code, and it handles the complex parts. Let’s set up the foundation.

Start a new Next.js project with the App Router and TypeScript. Then, install the necessary packages.

npm install uploadthing @uploadthing/react @prisma/client
npm install -D prisma

Next, configure your environment. You’ll need keys from Uploadthing and a database connection string.

# .env
UPLOADTHING_SECRET="your_secret_key"
UPLOADTHING_APP_ID="your_app_id"
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

With the packages installed, we define our data structure. Using Prisma, we can model exactly what information we want to keep about each upload.

// prisma/schema.prisma
model FileRecord {
  id        String   @id @default(cuid())
  key       String   @unique // The unique identifier from Uploadthing
  name      String   // Original filename
  size      Int      // File size in bytes
  url       String   // The URL to access the file
  userId    String   // Who uploaded it?
  uploadedAt DateTime @default(now())
}

Run npx prisma migrate dev to create the table in your database. This model is simple but gives us a solid link between the file in storage and our application’s data. How will we ensure only certain file types are accepted, though?

The core of our type-safe system is the Uploadthing file router. This is where you declare the “endpoints” for uploading, along with their specific rules.

// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from "uploadthing/next";

const f = createUploadthing();

export const ourFileRouter = {
  profileImage: f({
    image: { maxFileSize: "4MB", maxFileCount: 1 }
  })
    .middleware(async ({ req }) => {
      // Simulate fetching user info
      const user = { id: "user_123" };
      return { userId: user.id };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      // This code runs on the server after successful upload
      console.log("Upload complete for userId:", metadata.userId);
      console.log("File URL:", file.url);

      // Here you would save to your database
      // await prisma.fileRecord.create({ data: {...} })
      return { uploadedBy: metadata.userId };
    }),
} satisfies FileRouter;

export type OurFileRouter = typeof ourFileRouter;

Notice the structure: we define an upload “route” called profileImage. It only accepts images under 4MB. The middleware runs before the upload begins, allowing for authentication. The onUploadComplete callback runs after, giving us the perfect place to save data to our Prisma database. Can you see how the metadata flows from the middleware to the completion handler?

We need to expose this router to Uploadthing’s servers via a simple API route.

// app/api/uploadthing/route.ts
import { createRouteHandler } from "uploadthing/next";
import { ourFileRouter } from "./core";

export const { GET, POST } = createRouteHandler({
  router: ourFileRouter,
});

Now for the frontend magic. We can use Uploadthing’s React hook to build a component that knows about our router’s exact types.

// app/components/UploadButton.tsx
"use client";

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

export default function OurUploadButton() {
  return (
    <UploadButton<OurFileRouter>
      endpoint="profileImage"
      onClientUploadComplete={(res) => {
        if (res) {
          console.log("Files uploaded on client:", res);
          alert("Upload Completed! URL: " + res[0].url);
        }
      }}
      onUploadError={(error: Error) => {
        alert(`ERROR! ${error.message}`);
      }}
    />
  );
}

The <UploadButton> component is given our router type. This means the endpoint prop can only be one we defined, like "profileImage". The onClientUploadComplete callback receives perfectly typed response data. This end-to-end type safety eliminates an entire class of errors where the frontend and backend disagree on data shape.

But what about showing the user a progress bar or the image they just uploaded? We need more control. We can build a custom interface using the useUploadThing hook.

// app/components/CustomUploader.tsx
"use client";

import { useUploadThing } from "@/lib/uploadthing";
import { useState } from "react";

export function CustomUploader() {
  const [files, setFiles] = useState<File[]>([]);
  const { startUpload, isUploading } = useUploadThing("profileImage");

  const handleSubmit = async () => {
    if (files.length === 0) return;
    const res = await startUpload(files);
    if (res) {
      // Save to your database via a Server Action
      await saveToDatabase(res[0]);
      setFiles([]);
    }
  };

  return (
    <div>
      <input
        type="file"
        accept="image/*"
        onChange={(e) => setFiles(Array.from(e.target.files || []))}
      />
      <button onClick={handleSubmit} disabled={isUploading}>
        {isUploading ? "Uploading..." : "Upload"}
      </button>
    </div>
  );
}

// Server Action to save the record
async function saveToDatabase(file: { key: string; name: string; size: number; url: string }) {
  "use server";
  // Use Prisma to save the file record here
  // await prisma.fileRecord.create({ data: file });
}

This pattern is powerful. The useUploadThing hook provides the startUpload function and an isUploading state. We can build any UI around it—a drag-and-drop zone, a progress indicator, or a preview gallery. The Server Action saveToDatabase then securely links the uploaded file to our user’s record in the database on our server.

The result is a seamless loop: secure upload, type-safe processing, and persistent storage. The user gets immediate feedback, and your application gains a reliable, auditable record of every file. This method simplifies a traditionally complex task, letting you focus on building features instead of managing upload infrastructure.

I hope this walkthrough gives you a clear path to improving file handling in your own projects. It certainly cleaned up my code. If you found this useful, please share it with a colleague who might be battling with file uploads. I’d love to hear about your implementation in the comments below. What other application features would you build around this core upload system?


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, TypeScript, secure file upload



Similar Posts
Blog Image
Build a Real-Time Collaborative Document Editor: Socket.io, Operational Transform & MongoDB Tutorial

Build real-time collaborative document editor with Socket.io, Operational Transform & MongoDB. Learn conflict-free editing, synchronization & scalable architecture.

Blog Image
Build High-Performance Task Queue with BullMQ Redis TypeScript Complete Guide

Learn to build scalable task queues with BullMQ, Redis & TypeScript. Master async processing, error handling, monitoring & production deployment.

Blog Image
Complete Guide to Building Type-Safe GraphQL APIs with TypeScript, Apollo Server, and Prisma

Learn to build production-ready type-safe GraphQL APIs with TypeScript, Apollo Server & Prisma. Complete guide with subscriptions, auth & deployment tips.

Blog Image
Build High-Performance GraphQL API: NestJS, Prisma, Redis Caching Guide 2024

Learn to build a scalable GraphQL API with NestJS, Prisma, and Redis caching. Master advanced patterns, authentication, real-time subscriptions, and performance optimization techniques.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Applications

Learn how to integrate Next.js with Prisma ORM for type-safe full-stack applications. Build robust database-driven apps with seamless TypeScript support.

Blog Image
Build Event-Driven Architecture with Redis Streams and Node.js: Complete Implementation Guide

Master event-driven architecture with Redis Streams & Node.js. Learn producers, consumers, error handling, monitoring & scaling. Complete tutorial with code examples.