DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

UploadThing in Next.js 2026: File Uploads Without the S3 Pain

Setting up file uploads directly with S3 in Next.js means: presigned URLs, CORS config, IAM roles, auth middleware, file validation, progress tracking, and 200+ lines of boilerplate before you even store the file URL in your database.

UploadThing does all of that in ~20 lines. The file goes directly from the browser to their CDN. Your server only runs auth and metadata storage. Here's the complete setup.


How UploadThing works

The architecture is simple:

  1. Browser requests an upload URL from your /api/uploadthing route
  2. Your .middleware() runs — auth check, returns metadata
  3. If auth passes, file goes directly browser → UploadThing CDN (your server never touches the bytes)
  4. .onUploadComplete() fires — you save the URL and metadata to your database

Your server bandwidth usage: zero. Your server memory usage: zero. No matter how large the file is.


Setup

Install

npm install uploadthing @uploadthing/react
Enter fullscreen mode Exit fullscreen mode

Environment variables

UPLOADTHING_SECRET=sk_live_...   # from uploadthing.com dashboard
UPLOADTHING_APP_ID=...
Enter fullscreen mode Exit fullscreen mode

The File Router

This is the core of UploadThing. One file defines everything: what file types are allowed, who's authenticated, and what happens after upload.

// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { auth } from '@clerk/nextjs/server';
import { db } from '@/lib/db';
import { files } from '@/lib/schema';

const f = createUploadthing();

export const ourFileRouter = {
  // Profile picture uploader
  profilePicture: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
    .middleware(async ({ req }) => {
      const { userId } = await auth();
      if (!userId) throw new Error('Unauthorized');
      return { userId };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      await db.insert(files).values({
        userId: metadata.userId,
        fileUrl: file.url,
        fileKey: file.key,    // ← store this, you need it for deletion
        fileName: file.name,
        fileSize: file.size,
      });
      return { url: file.url };
    }),

  // Document uploader with multiple file types
  documentUploader: f({
    pdf: { maxFileSize: '16MB' },
    'application/msword': { maxFileSize: '8MB' },
  })
    .middleware(async () => {
      const { userId } = await auth();
      if (!userId) throw new Error('Unauthorized');
      return { userId };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      console.log('Uploaded:', file.url, 'for user:', metadata.userId);
    }),
} satisfies FileRouter;

export type OurFileRouter = typeof ourFileRouter;
Enter fullscreen mode Exit fullscreen mode

The route handler

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

export const { GET, POST } = createRouteHandler({ router: ourFileRouter });
Enter fullscreen mode Exit fullscreen mode

That's it for the server side.


Upload components

UploadThing provides two ready-made React components. Generate them once:

// lib/uploadthing.ts
import { generateReactHelpers } from '@uploadthing/react';
import type { OurFileRouter } from '@/app/api/uploadthing/core';

export const { useUploadThing, UploadButton, UploadDropzone } =
  generateReactHelpers<OurFileRouter>();
Enter fullscreen mode Exit fullscreen mode

UploadButton — click to select

'use client';
import { UploadButton } from '@/lib/uploadthing';

export function ProfilePictureUpload() {
  return (
    <UploadButton
      endpoint="profilePicture"
      onClientUploadComplete={(res) => {
        console.log('Uploaded:', res[0].url);
        // update your UI
      }}
      onUploadError={(error) => {
        alert(`Upload failed: ${error.message}`);
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

UploadDropzone — drag & drop

'use client';
import { UploadDropzone } from '@/lib/uploadthing';

export function DocumentDropzone() {
  return (
    <UploadDropzone
      endpoint="documentUploader"
      onClientUploadComplete={(res) => {
        console.log('Files:', res.map(f => f.url));
      }}
      onUploadError={(error) => {
        alert(`Error: ${error.message}`);
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Both show a built-in progress bar, file name, and size. Fully customizable with className.


Deleting files — the part everyone forgets

Deleting the database row does NOT delete the file from storage. You end up with orphaned files that accumulate and cost you.

// app/actions/files.ts
'use server';
import { UTApi } from 'uploadthing/server';
import { db } from '@/lib/db';
import { files } from '@/lib/schema';
import { eq } from 'drizzle-orm';

const utapi = new UTApi();

export async function deleteFile(fileId: string) {
  const file = await db.query.files.findFirst({ where: eq(files.id, fileId) });
  if (!file) throw new Error('File not found');

  // Delete from UploadThing storage FIRST
  await utapi.deleteFiles(file.fileKey);

  // Then delete from your database
  await db.delete(files).where(eq(files.id, fileId));
}
Enter fullscreen mode Exit fullscreen mode

Always store fileKey (from file.key in onUploadComplete) alongside fileUrl. You need both.


What to store in the database

// Your files table schema (Drizzle)
export const files = pgTable('files', {
  id: uuid('id').defaultRandom().primaryKey(),
  userId: text('user_id').notNull(),
  fileUrl: text('file_url').notNull(),    // for displaying the file
  fileKey: text('file_key').notNull(),    // for deleting the file
  fileName: text('file_name').notNull(),
  fileSize: integer('file_size').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});
Enter fullscreen mode Exit fullscreen mode

Production checklist

  • [ ] UPLOADTHING_SECRET is server-side only — never expose it to the client
  • [ ] Auth check in .middleware() — throw to reject the upload
  • [ ] Store both fileUrl AND fileKey in your database
  • [ ] Delete from UploadThing + DB atomically — use a Server Action
  • [ ] Set maxFileSize and maxFileCount on every endpoint
  • [ ] Handle onUploadError on the client — never swallow errors silently
  • [ ] Use generateReactHelpers for type-safe endpoint names

Free tier

2 GB storage · 2 GB bandwidth/month · No credit card required. Generous enough to ship your MVP and validate the product before paying anything.


Full guide: stacknotice.com/blog/uploadthing-nextjs-complete-guide-2026

Top comments (0)