# UploadThing: A Modern File Upload Solution for Next.js Applications

> Build secure and type-safe file uploads in your Next.js applications with UploadThing. Modern file handling with built-in CDN delivery and seamless integration.

By Harshal Ranjhani · [HTML version](https://harshalranjhani.in/blog/uploadthing-a-modern-file-upload-solution-for-nextjs-applications)

## Introduction

UploadThing is an open-source file upload solution specifically designed for Next.js applications. It provides developers with a type-safe, efficient way to handle file uploads while offering features like file validation, transformation, and direct integration with popular frameworks.

## Technical Overview

At its core, UploadThing consists of three main components:

1. Server-side file router
2. Client-side components and hooks
3. Type-safe API endpoints

### Installation and Basic Setup

First, install the required packages:

```bash
npm install uploadthing @uploadthing/react
```

Create a file router (typically in `app/api/uploadthing/core.ts`):

```typescript
import { createUploadthing, type FileRouter } from "uploadthing/server";

const f = createUploadthing();

export const uploadRouter = {
  // Example "profile picture upload" route - these can be named whatever you want!
  profilePicture: f(["image"])
    .middleware(({ req }) => auth(req))
    .onUploadComplete((data) => console.log("file", data)),

  // This route takes an attached image OR video
  messageAttachment: f(["image", "video"])
    .middleware(({ req }) => auth(req))
    .onUploadComplete((data) => console.log("file", data)),

  // Takes exactly ONE image up to 2MB
  strictImageAttachment: f({
    image: { maxFileSize: "2MB", maxFileCount: 1, minFileCount: 1 },
  })
    .middleware(({ req }) => auth(req))
    .onUploadComplete((data) => console.log("file", data)),

  // Takes up to 4 2mb images and/or 1 256mb video
  mediaPost: f({
    image: { maxFileSize: "2MB", maxFileCount: 4 },
    video: { maxFileSize: "256MB", maxFileCount: 1 },
  })
    .middleware(({ req }) => auth(req))
    .onUploadComplete((data) => console.log("file", data)),

  // Takes up to 4 2mb images, and the client will not resolve
  // the upload until the `onUploadComplete` resolved.
  withAwaitedServerData: f(
    { image: { maxFileSize: "2MB", maxFileCount: 4 } },
    { awaitServerData: true },
  )
    .middleware(({ req }) => auth(req))
    .onUploadComplete((data) => {
      return { foo: "bar" as const };
    }),
} satisfies FileRouter;

export type UploadRouter = typeof uploadRouter;
```

These are the routes you create with the helper instantiated by `createUploadthing`. Think of them as the "endpoints" for what your users can upload. An object with file routes constructs a file router where the keys (slugs) in the object are the names of your endpoints.

**Route Config**:

The `f` function takes two arguments. The first can be an array of `FileType`, or a record mapping each `FileType` with a route config. The route config allow more granular control, for example what files can be uploaded and how many of them can be uploaded for a given upload. The array syntax will fallback to applying the defaults to all file types.

A `FileType` can be any valid [web MIME type ↗](https://developer.mozilla.org/en-US/docs/Web/HTTP/MIME_types). For example: use `application/json` to only allow JSON files to be uploaded.

Additionally, you may pass any of the following custom types: `image`, `video`, `audio`, `pdf` or `text`. These are shorthands that allows you to specify the type of file without specifying the exact MIME type. Lastly, there's `blob` which allows any file type.

**Route Options**:

The second argument to the `f` function is an optional object of route options. These configurations provide global settings that affect how the upload route behaves.

Available route options:

- **awaitServerData**: (boolean, default: false)
  When set to `true`, the client will wait for the server's `onUploadComplete` handler to finish and return data before running `onClientUploadComplete`. This is useful when you need to ensure server-side processing is complete before proceeding with client-side operations.

  Example:
  ```typescript
  const uploadRouter = {
    withServerData: f(
      { image: { maxFileSize: "2MB" } },
      { awaitServerData: true }
    )
      .middleware(({ req }) => ({ userId: "123" }))
      .onUploadComplete(async ({ metadata }) => {
        // This will complete before client-side callback
        const result = await processImage(metadata);
        return { processedUrl: result.url };
      }),
  }
  ```

### Route Methods

The `f` function returns a builder object that allows you to chain the following methods:

1. **input**
   Validates user input from the client using schema validators. This method ensures that any additional data sent alongside the file upload meets your specifications.

   Supported validators:
   - Zod (≥3)
   - Effect/Schema (≥3.10, with limitations)
   - Standard Schema specification (e.g., Valibot ≥1.0, ArkType ≥2.0)

   Example with complex validation:
   ```typescript
   f(["image"])
     .input(
       z.object({
         title: z.string().min(1).max(100),
         tags: z.array(z.string()),
         isPublic: z.boolean()
       })
     )
     .middleware(async ({ req, input }) => {
       // input is fully typed with:
       // { title: string; tags: string[]; isPublic: boolean }
       return { metadata: input };
     })
   ```

2. **middleware**
   Handles authorization and metadata tagging. This is where you perform authentication checks and prepare any data needed for the upload process.

   Example with comprehensive auth and metadata:
   ```typescript
   f(["image"])
     .middleware(async ({ req, res }) => {
       const user = await currentUser();
       if (!user) throw new UploadThingError("Authentication required");
       
       // You can perform additional checks
       const userPlan = await getUserSubscriptionPlan(user.id);
       if (userPlan === "free" && await getUserUploadCount(user.id) > 10) {
         throw new UploadThingError("Free plan limit reached");
       }

       return { 
         userId: user.id,
         planType: userPlan,
         timestamp: new Date().toISOString()
       };
     })
   ```

3. **onUploadError**
   Called when an upload error occurs. Use this to handle errors gracefully and perform any necessary cleanup or logging.

   Parameters:
   - `error`: UploadThingError (contains error message and code)
   - `fileKey`: string (unique identifier for the failed upload)

   Example:
   ```typescript
   f(["image"])
     .onUploadError(async ({ error, fileKey }) => {
       await logger.error("Upload failed", {
         error: error.message,
         fileKey,
         code: error.code
       });
       await cleanupFailedUpload(fileKey);
     })
   ```

4. **onUploadComplete**
   Final handler for successful uploads. This is where you can process the uploaded file and perform any necessary post-upload operations.

   Parameters:
   - `metadata`: Data passed from middleware
   - `file`: UploadedFileData object containing:
     - `name`: Original file name
     - `size`: File size in bytes
     - `key`: Unique file identifier
     - `url`: Public URL of the uploaded file

   Example with comprehensive handling:
   ```typescript
   f(["image"])
     .onUploadComplete(async ({ metadata, file }) => {
       // Store file reference in database
       await db.files.create({
         data: {
           userId: metadata.userId,
           fileName: file.name,
           fileSize: file.size,
           fileUrl: file.url,
           uploadedAt: metadata.timestamp
         }
       });

       // Trigger any post-upload processing
       await imageProcessor.optimize(file.url);

       // Return data to client if awaitServerData is true
       return {
         fileId: file.key,
         accessUrl: file.url,
         processedAt: new Date().toISOString()
       };
     })
   ```

## Uploading Files

UploadThing provides two primary methods for uploading files: Client-Side Uploads and Server-Side Uploads. Each approach has its own benefits and use cases.

### Client-Side Uploads

Client-side uploads are the recommended approach as they offer several advantages:
- Reduced server costs (no ingress/egress fees)
- Direct file transfer to UploadThing
- Built-in validation and type safety

The process works as follows:

1. Client initiates upload request
2. Server generates presigned URLs
3. Client uploads directly to UploadThing
4. Server receives callback on completion

Example implementation using React:
```typescript
import { UploadButton } from "@uploadthing/react";

export default function UploadPage() {
  return (
    <UploadButton
      endpoint="imageUploader"
      onClientUploadComplete={(res) => {
        console.log("Files: ", res);
        alert("Upload Completed");
      }}
      onUploadError={(error: Error) => {
        alert(`ERROR! ${error.message}`);
      }}
    />
  );
}
```

### Server-Side Uploads

Server-side uploads are useful when you need to:
- Process or validate files before uploading
- Generate files on the server
- Handle specific security requirements

Example using the UTApi:
```typescript
import { UTApi } from "uploadthing/server";

const utapi = new UTApi();

async function uploadServerSideFile(file: File) {
  try {
    const response = await utapi.uploadFiles(file);
    return response.data;
  } catch (error) {
    console.error("Upload failed:", error);
    throw error;
  }
}
```

### Upload Configuration

Both client and server uploads support various configuration options:

1. **File Validation**:
```typescript
const uploadRouter = {
  strictImageUpload: f({
    image: {
      maxFileSize: "4MB",
      maxFileCount: 1,
      minFileCount: 1
    }
  })
}
```

2. **Custom Metadata**:
```typescript
f(["image"])
  .middleware(({ req }) => ({
    userId: req.userId,
    uploadedAt: new Date().toISOString()
  }))
```

3. **Upload Callbacks**:
```typescript
f(["image"])
  .onUploadComplete(async ({ metadata, file }) => {
    // Handle successful upload
    await db.images.create({
      data: {
        userId: metadata.userId,
        url: file.url,
        name: file.name
      }
    });
  })
  .onUploadError(({ error }) => {
    // Handle upload error
    console.error("Upload failed:", error);
  });
```

### Resumable Uploads

UploadThing supports resumable uploads for large files:

```typescript
const upload = async (file: File, presignedUrl: string) => {
  // Get the current range start
  const rangeStart = await fetch(presignedUrl, { 
    method: "HEAD" 
  }).then((res) =>
    parseInt(res.headers.get("x-ut-range-start") ?? "0", 10)
  );

  // Continue upload from last successful byte
  await fetch(presignedUrl, {
    method: "PUT",
    headers: {
      Range: `bytes=${rangeStart}-`,
    },
    body: file.slice(rangeStart),
  });
};
```

### Security Considerations

1. **URL Signing**: All upload URLs are signed with your API key and include expiration timestamps
2. **File Validation**: Implement thorough validation in your file routes
3. **Authentication**: Always use middleware to authenticate users before allowing uploads

Example secure configuration:
```typescript
const uploadRouter = {
  secureUpload: f(["image"])
    .middleware(async ({ req }) => {
      const user = await auth(req);
      if (!user) throw new Error("Unauthorized");
      
      const userQuota = await checkUserQuota(user.id);
      if (!userQuota.hasRemaining) {
        throw new Error("Upload quota exceeded");
      }

      return { userId: user.id };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      await saveToDatabase(metadata.userId, file);
      await updateUserQuota(metadata.userId);
    })
};
```

## Working with Files

After successfully uploading files to UploadThing, there are several ways to work with and access these files. Here's a comprehensive guide on file operations.

### Accessing Public Files

Files are served through UploadThing's CDN using the following URL pattern:
```
https://<APP_ID>.ufs.sh/f/<FILE_KEY>
```

If you've set a `customId` during upload, you can also access files using:
```
https://<APP_ID>.ufs.sh/f/<CUSTOM_ID>
```

> **Important**: Never use raw storage provider URLs (e.g., `https://bucket.s3.region.amazonaws.com/<FILE_KEY>`). UploadThing may change storage providers or buckets, making these URLs unreliable.

#### Setting Up Image Optimization (Next.js Example)

```typescript
/** @type {import('next').NextConfig} */
export default {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "<APP_ID>.ufs.sh",
        pathname: "/f/*",
      },
    ],
  },
};
```

## UTApi Reference

The UploadThing API Helper is designed for server-side use. While it provides a REST API interface, it offers enhanced functionality and type safety.

> Note: External API calls will typically be slower than querying your own database. It's recommended to store necessary file data in your database, either in `onUploadComplete()` or after using `uploadFiles()`, rather than relying on the API for core data flow.

### Constructor

Initialize an instance of `UTApi`:

```typescript:server/uploadthing.ts
import { UTApi } from "uploadthing/server";

export const utapi = new UTApi({
  // ...options
});
```

#### Configuration Options:
- **fetch**: Custom fetch function
- **token**: Your UploadThing token (default: env.UPLOADTHING_TOKEN)
- **logLevel**: Logging verbosity (Error | Warning | Info | Debug | Trace)
- **logFormat**: Log format (json | logFmt | structured | pretty)
- **defaultKeyType**: Default key type for file operations ('fileKey' | 'customId')
- **apiUrl**: UploadThing API URL (default: https://api.uploadthing.com)
- **ingestUrl**: UploadThing Ingest API URL

### File Operations

#### Upload Files
```typescript
import { utapi } from "~/server/uploadthing";

async function uploadFiles(formData: FormData) {
  "use server";
  const files = formData.getAll("files");
  const response = await utapi.uploadFiles(files);
}
```

#### Upload Files from URL
```typescript
const fileUrl = "https://test.com/some.png";
const uploadedFile = await utapi.uploadFilesFromUrl(fileUrl);

const fileUrls = ["https://test.com/some.png", "https://test.com/some2.png"];
const uploadedFiles = await utapi.uploadFilesFromUrl(fileUrls);
```

#### Delete Files
```typescript
await utapi.deleteFiles("2e0fdb64-9957-4262-8e45-f372ba903ac8_image.jpg");
await utapi.deleteFiles([
  "2e0fdb64-9957-4262-8e45-f372ba903ac8_image.jpg",
  "1649353b-04ea-48a2-9db7-31de7f562c8d_image2.jpg",
]);
```

#### List Files
```typescript
const files = await utapi.listFiles({
  limit: 500,  // optional, default: 500
  offset: 0    // optional, default: 0
});
```

#### Rename Files
```typescript
await utapi.renameFiles({
  key: "2e0fdb64-9957-4262-8e45-f372ba903ac8_image.jpg",
  newName: "myImage.jpg",
});

// Batch rename
await utapi.renameFiles([
  {
    key: "2e0fdb64-9957-4262-8e45-f372ba903ac8_image.jpg",
    newName: "myImage.jpg",
  },
  {
    key: "1649353b-04ea-48a2-9db7-31de7f562c8d_image2.jpg",
    newName: "myOtherImage.jpg",
  },
]);
```

#### Get Signed URL
```typescript
const fileKey = "2e0fdb64-9957-4262-8e45-f372ba903ac8_image.jpg";
const url = await utapi.getSignedURL(fileKey, {
  expiresIn: 60 * 60, // 1 hour
  // or use time strings:
  // expiresIn: '1 hour',
  // expiresIn: '3d',
  // expiresIn: '7 days',
});
```

#### Update ACL
```typescript
// Make a single file public
await utapi.updateACL(
  "2e0fdb64-9957-4262-8e45-f372ba903ac8_image.jpg",
  "public-read"
);

// Make multiple files private
await utapi.updateACL(
  [
    "2e0fdb64-9957-4262-8e45-f372ba903ac8_image.jpg",
    "1649353b-04ea-48a2-9db7-31de7f562c8d_image2.jpg",
  ],
  "private"
);
```

### Accessing Private Files

For files protected by access controls, you'll need to generate short-lived presigned URLs. There are two ways to do this:

1. **Using UTApi**:
```typescript
import { UTApi } from "uploadthing/server";

const utapi = new UTApi();

async function getFileAccess(fileKey: string) {
  const signedUrl = await utapi.getSignedUrl(fileKey, {
    expiresIn: "1h" // Optional expiration time
  });
  return signedUrl;
}
```

2. **Using REST API Endpoint**:
```typescript
async function requestFileAccess(fileKey: string) {
  const response = await fetch("/api/requestFileAccess", {
    method: "POST",
    body: JSON.stringify({ fileKey })
  });
  const { signedUrl } = await response.json();
  return signedUrl;
}
```

### File Operations

The UTApi provides several methods for managing files:

**Deleting Files**:
```typescript
const utapi = new UTApi();

async function deleteFile(fileKey: string) {
  try {
    await utapi.deleteFiles(fileKey);
    console.log("File deleted successfully");
  } catch (error) {
    console.error("Error deleting file:", error);
  }
}
```

**Checking File Status**:
```typescript
async function checkFileStatus(fileKey: string) {
  try {
    const status = await utapi.getFileStatus(fileKey);
    return status;
  } catch (error) {
    console.error("Error checking file status:", error);
    throw error;
  }
}
```

**Renaming Files**:
```typescript
async function renameFile(fileKey: string, newName: string) {
  try {
    await utapi.renameFile(fileKey, newName);
    console.log("File renamed successfully");
  } catch (error) {
    console.error("Error renaming file:", error);
  }
}
```

### Best Practices

**URL Management**:
   - Always use the CDN URLs provided by UploadThing
   - Store file keys rather than full URLs in your database
   - Generate presigned URLs on-demand for private files

**Security**:
   - Implement proper access controls in your middleware
   - Use short expiration times for presigned URLs
   - Validate file access permissions before generating signed URLs

**Performance**:
   - Utilize the CDN for optimal file delivery
   - Consider implementing caching for frequently accessed files
   - Use appropriate image optimization settings

Example implementation combining these practices:

```typescript
const fileManager = {
  async getFileUrl(fileKey: string, userId: string) {
    // Check user permissions
    const hasAccess = await checkUserFileAccess(userId, fileKey);
    if (!hasAccess) {
      throw new Error("Unauthorized access");
    }

    // Get cached URL if available
    const cachedUrl = await cache.get(`file:${fileKey}`);
    if (cachedUrl) return cachedUrl;

    // Generate new signed URL
    const signedUrl = await utapi.getSignedUrl(fileKey, {
      expiresIn: "1h"
    });

    // Cache the URL (for slightly less than expiration time)
    await cache.set(`file:${fileKey}`, signedUrl, 50 * 60); // 50 minutes

    return signedUrl;
  },

  async deleteUserFile(fileKey: string, userId: string) {
    // Verify ownership
    const isOwner = await verifyFileOwnership(userId, fileKey);
    if (!isOwner) {
      throw new Error("Unauthorized deletion");
    }

    // Delete file
    await utapi.deleteFiles(fileKey);
    
    // Clean up database records
    await db.files.delete({
      where: { fileKey }
    });

    // Clear cache
    await cache.del(`file:${fileKey}`);
  }
};
```

## Conclusion

UploadThing provides a robust, type-safe solution for handling file uploads in Next.js applications. Its key strengths include:

- **Developer Experience**: Type-safe APIs and intuitive integration with React components
- **Flexibility**: Support for both client and server-side uploads with customizable workflows
- **Security**: Built-in file validation, access controls, and secure URL signing
- **Performance**: CDN-backed delivery and resumable uploads for large files

Whether you're building a simple image upload feature or a complex file management system, UploadThing offers the tools and flexibility needed to implement secure and efficient file handling in your applications.

For more information and updates, visit the [official UploadThing documentation](https://docs.uploadthing.com/).

Last updated on January 11, 2025
