Tutorials
|stacknotice.com
13 min left|
0%
|2,600 words
Tutorials

UploadThing in Next.js 2026: The Complete Production File Upload Guide

Complete guide to file uploads in Next.js 15 with UploadThing. File router, Clerk auth middleware, Drizzle metadata storage, drag & drop, and error handling.

C
Carlos Oliva
Software Developer
June 5, 202613 min read
Share:
UploadThing in Next.js 2026: The Complete Production File Upload Guide

Every SaaS app eventually needs file uploads — profile photos, document attachments, image galleries. The naive solution is to send the file to your Next.js API route, which proxies it to S3. This works until your Vercel function times out on a 10MB upload, or you discover you're paying twice for the bandwidth.

UploadThing solves this by handling the upload directly from the browser to storage, using a server-side file router for configuration and auth. This guide covers the full production setup: file router with Clerk auth, storing file metadata with Drizzle, drag & drop UI, and error handling.


Why UploadThing over direct S3

Direct S3UploadThing
SetupIAM roles, presigned URLs, CORS config15 minutes
TypeScriptManual typesFull type-safe file router
AuthManual per uploadMiddleware in file router
CostS3 bandwidth ($0.09/GB)Free: 2 GB storage + 2 GB bandwidth/month
File validationManualConfigured in router (type, size)
Resumable uploadsDIYBuilt-in for large files

Direct S3 is the right choice when you need fine-grained control, custom storage backends, or very high upload volumes. For most SaaS apps, UploadThing is the faster and cheaper path.


Installation and environment

npm install uploadthing @uploadthing/react

Get your API keys from uploadthing.com, create a project, and add them to .env.local:

UPLOADTHING_TOKEN=eyJhbGci...

That's the only environment variable you need. UploadThing v7+ consolidates app ID and secret into a single token.


The File Router — the core concept

The File Router is where you define what your app accepts: file types, size limits, and auth logic. Every upload goes through a route defined here.

// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { auth } from '@clerk/nextjs/server';
 
const f = createUploadthing();
 
export const ourFileRouter = {
  // Profile photo — one image, max 4MB
  profilePhoto: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } })
    .middleware(async () => {
      const { userId } = await auth();
      if (!userId) throw new Error('Unauthorized');
      // Whatever you return here is available in onUploadComplete
      return { userId };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      // Called server-side after upload finishes
      await db.update(users)
        .set({ avatarUrl: file.url })
        .where(eq(users.clerkId, metadata.userId));
 
      return { url: file.url };
    }),
 
  // Document attachments — multiple PDFs, max 16MB each
  documentAttachment: f({
    pdf: { maxFileSize: '16MB', maxFileCount: 10 },
    'application/msword': { maxFileSize: '16MB', maxFileCount: 10 },
  })
    .middleware(async ({ req }) => {
      const { userId } = await auth();
      if (!userId) throw new Error('Unauthorized');
 
      // You can fetch from DB in middleware too
      const user = await db.query.users.findFirst({
        where: eq(users.clerkId, userId),
      });
      if (!user) throw new Error('User not found');
 
      return { userId, organizationId: user.organizationId };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      // Save file metadata to your database
      await db.insert(attachments).values({
        name: file.name,
        url: file.url,
        size: file.size,
        type: file.type,
        organizationId: metadata.organizationId,
        uploadedById: metadata.userId,
      });
 
      return { name: file.name, url: file.url };
    }),
 
  // General image uploads (posts, content)
  contentImage: f({ image: { maxFileSize: '8MB', maxFileCount: 4 } })
    .middleware(async () => {
      const { userId } = await auth();
      if (!userId) throw new Error('Unauthorized');
      return { userId };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      return { url: file.url };
    }),
} satisfies FileRouter;
 
export type OurFileRouter = typeof ourFileRouter;
What you return from onUploadComplete comes back to the client

The object returned from onUploadComplete is sent to the onClientUploadComplete callback in your upload component. Use this to pass the file URL back after the server has saved it.


Route handler

// app/api/uploadthing/route.ts
import { createRouteHandler } from 'uploadthing/next';
import { ourFileRouter } from './core';
 
export const { GET, POST } = createRouteHandler({ router: ourFileRouter });

Database schema for file metadata

If you're storing file metadata in Drizzle:

// src/db/schema.ts
import { pgTable, text, integer, timestamp, uuid } from 'drizzle-orm/pg-core';
 
export const attachments = pgTable('attachments', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  url: text('url').notNull(),
  size: integer('size').notNull(), // bytes
  type: text('type').notNull(),
  organizationId: uuid('organization_id').references(() => organizations.id).notNull(),
  uploadedById: text('uploaded_by_id').notNull(), // Clerk user ID
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

Upload components

UploadThing provides two ready-made React components. First, generate typed wrappers from your file router:

// utils/uploadthing.ts
import { generateUploadButton, generateUploadDropzone } from '@uploadthing/react';
import type { OurFileRouter } from '@/app/api/uploadthing/core';
 
export const UploadButton = generateUploadButton<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();

Profile photo with UploadButton

// components/ProfilePhotoUpload.tsx
'use client';
 
import { useState } from 'react';
import Image from 'next/image';
import { UploadButton } from '@/utils/uploadthing';
 
interface Props {
  currentAvatarUrl: string | null;
  onUploadComplete?: (url: string) => void;
}
 
export function ProfilePhotoUpload({ currentAvatarUrl, onUploadComplete }: Props) {
  const [avatarUrl, setAvatarUrl] = useState(currentAvatarUrl);
 
  return (
    <div className="flex flex-col items-center gap-4">
      {avatarUrl && (
        <Image
          src={avatarUrl}
          alt="Profile photo"
          width={80}
          height={80}
          className="rounded-full object-cover"
        />
      )}
 
      <UploadButton
        endpoint="profilePhoto"
        onClientUploadComplete={(res) => {
          const url = res[0]?.url;
          if (url) {
            setAvatarUrl(url);
            onUploadComplete?.(url);
          }
        }}
        onUploadError={(error) => {
          console.error('Upload failed:', error.message);
          alert(`Upload failed: ${error.message}`);
        }}
        appearance={{
          button: 'bg-orange-500 text-white rounded-lg px-4 py-2 text-sm font-medium',
          allowedContent: 'text-gray-500 text-xs',
        }}
      />
    </div>
  );
}

Document upload with drag & drop

// components/DocumentUpload.tsx
'use client';
 
import { useState } from 'react';
import { UploadDropzone } from '@/utils/uploadthing';
 
interface UploadedFile {
  name: string;
  url: string;
}
 
export function DocumentUpload() {
  const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
  const [isUploading, setIsUploading] = useState(false);
 
  return (
    <div className="space-y-4">
      <UploadDropzone
        endpoint="documentAttachment"
        onUploadBegin={() => setIsUploading(true)}
        onClientUploadComplete={(res) => {
          setIsUploading(false);
          const newFiles = res.map(f => ({ name: f.name, url: f.url }));
          setUploadedFiles(prev => [...prev, ...newFiles]);
        }}
        onUploadError={(error) => {
          setIsUploading(false);
          console.error('Upload failed:', error.message);
        }}
        appearance={{
          container: 'border-2 border-dashed border-gray-300 rounded-xl p-8',
          label: 'text-gray-600 font-medium',
          allowedContent: 'text-gray-400 text-sm',
          uploadIcon: 'text-gray-400',
          button: 'bg-blue-600 text-white rounded-lg',
        }}
        content={{
          label: 'Drag & drop files here or click to browse',
          allowedContent: 'PDF, Word — up to 16MB per file, 10 files max',
        }}
      />
 
      {uploadedFiles.length > 0 && (
        <ul className="space-y-2">
          {uploadedFiles.map(file => (
            <li key={file.url} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
              <span className="text-sm font-medium">{file.name}</span>
              <a
                href={file.url}
                target="_blank"
                rel="noopener noreferrer"
                className="ml-auto text-blue-600 text-sm"
              >
                View
              </a>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Deleting files

UploadThing stores files on its CDN — you need to delete them there when a user removes a file from your app.

// app/actions/files.ts
'use server';
 
import { UTApi } from 'uploadthing/server';
import { auth } from '@clerk/nextjs/server';
import { db } from '@/db';
import { attachments } from '@/db/schema';
import { eq, and } from 'drizzle-orm';
 
const utapi = new UTApi();
 
export async function deleteAttachment(attachmentId: string) {
  const { userId } = await auth();
  if (!userId) throw new Error('Unauthorized');
 
  // Find the file — ensure the user owns it
  const attachment = await db.query.attachments.findFirst({
    where: and(
      eq(attachments.id, attachmentId),
      eq(attachments.uploadedById, userId)
    ),
  });
 
  if (!attachment) throw new Error('File not found');
 
  // Extract the UploadThing file key from the URL
  // UT URLs look like: https://utfs.io/f/abc123def456
  const fileKey = attachment.url.split('/f/')[1];
 
  // Delete from UploadThing storage
  await utapi.deleteFiles(fileKey);
 
  // Delete from your database
  await db.delete(attachments).where(eq(attachments.id, attachmentId));
}

Handling errors

The most common errors and what they mean:

FileSizeMismatch — the file is larger than maxFileSize in your router. Either increase the limit or validate client-side before upload:

onUploadError={(error) => {
  if (error.code === 'FileSizeMismatch') {
    alert('File too large. Maximum size is 16MB.');
  } else if (error.code === 'InvalidFileType') {
    alert('Invalid file type. Please upload a PDF or Word document.');
  } else {
    alert(`Upload failed: ${error.message}`);
  }
}}

Unauthorized (thrown from middleware) — your auth check failed. Make sure Clerk middleware is configured in middleware.ts and the route is protected.

InternalServerError from onUploadComplete — the file uploaded successfully but your server-side handler threw. The file exists in UploadThing storage but isn't saved in your DB. Log this aggressively — you may have orphaned files.

.onUploadComplete(async ({ metadata, file }) => {
  try {
    await db.insert(attachments).values({ ... });
  } catch (error) {
    // Log but don't re-throw — the upload already succeeded
    // Consider a background job to clean up orphaned UT files
    console.error('[uploadthing] Failed to save file metadata:', {
      fileKey: file.key,
      userId: metadata.userId,
      error,
    });
    throw error; // Will show as error on client
  }
})

File type reference

Common MIME types for your file router:

f({
  // Images
  image: { maxFileSize: '4MB' },                   // jpg, png, gif, webp, etc.
  'image/svg+xml': { maxFileSize: '1MB' },
 
  // Documents
  pdf: { maxFileSize: '16MB' },
  'application/msword': { maxFileSize: '16MB' },   // .doc
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { maxFileSize: '16MB' }, // .docx
 
  // Video
  video: { maxFileSize: '256MB' },
 
  // Audio
  audio: { maxFileSize: '64MB' },
 
  // Catch-all (any type, not recommended for production)
  blob: { maxFileSize: '32MB' },
})

Production checklist

  • UPLOADTHING_TOKEN in environment variables — Vercel dashboard, not .env committed to git
  • Auth in every middleware — never skip the auth check in .middleware()
  • onUploadComplete errors logged — file uploads succeed even when your DB save fails; log these
  • Delete files when removing from DB — use UTApi.deleteFiles() to avoid orphaned files in storage
  • Client-side file type validation — don't rely solely on the file router; validate before upload for better UX
  • Test with large files — UploadThing handles multipart uploads automatically, but test your onUploadComplete with files near the size limit
  • Monitor UploadThing dashboard — track storage and bandwidth usage against your plan limits

Summary

UploadThing's pattern is simple once you understand it: define what you accept in the file router (type, size, auth), let the client upload directly to storage, then run onUploadComplete on the server to save metadata. The auth middleware pattern means you write auth logic once per upload type, not per request.

The two things that matter most for production: always validate auth in middleware, and always handle onUploadComplete failures so you don't end up with files in storage that aren't in your database.


Related: Drizzle ORM Complete Guide 2026 — storing file metadata with full type safety · Clerk + Next.js Authentication Guide — the auth layer that protects your upload routes · React Server Actions Complete Guide — the pattern behind onUploadComplete

#nextjs#typescript#webdev#tutorials#javascript
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.