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