file-upload

File upload patterns including multipart/form-data, presigned URLs for S3/R2, chunked uploads, progress tracking, drag-and-drop with react-dropzone, and client-side validation

39 stars

Best use case

file-upload is best used when you need a repeatable AI agent workflow instead of a one-off prompt.

File upload patterns including multipart/form-data, presigned URLs for S3/R2, chunked uploads, progress tracking, drag-and-drop with react-dropzone, and client-side validation

Teams using file-upload should expect a more consistent output, faster repeated execution, less prompt rewriting.

When to use this skill

  • You want a reusable workflow that can be run more than once with consistent structure.

When not to use this skill

  • You only need a quick one-off answer and do not need a reusable workflow.
  • You cannot install or maintain the underlying files, dependencies, or repository context.

Installation

Claude Code / Cursor / Codex

$curl -o ~/.claude/skills/file-upload/SKILL.md --create-dirs "https://raw.githubusercontent.com/InugamiDev/ultrathink-oss/main/.claude/skills/file-upload/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/file-upload/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How file-upload Compares

Feature / Agentfile-uploadStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/A

Frequently Asked Questions

What does this skill do?

File upload patterns including multipart/form-data, presigned URLs for S3/R2, chunked uploads, progress tracking, drag-and-drop with react-dropzone, and client-side validation

Where can I find the source code?

You can find the source code on GitHub using the link provided at the top of the page.

SKILL.md Source

# File Upload Patterns Skill

## Purpose

Implement secure, efficient file uploads from client to server or directly to cloud storage. Covers validation, progress tracking, presigned URLs for large files, and drag-and-drop UX.

## Upload Strategy Decision

| Method | Max Size | Complexity | Best For |
|--------|----------|------------|----------|
| **multipart/form-data** | ~10MB | Low | Simple forms, small files |
| **Presigned URL (S3/R2)** | 5GB | Medium | Large files, serverless |
| **Chunked/Resumable** | Unlimited | High | Video, unreliable networks |
| **Base64 in JSON** | ~1MB | Low | Avatars, thumbnails only |

## Key Patterns

### 1. Server-Side: Presigned URL Generation

```typescript
// app/api/upload/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';

const s3 = new S3Client({
  region: process.env.AWS_REGION!,
  // For Cloudflare R2:
  // endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
const MAX_SIZE = 10 * 1024 * 1024; // 10MB

export async function POST(req: Request) {
  const { filename, contentType, size } = await req.json();

  // Validate
  if (!ALLOWED_TYPES.includes(contentType)) {
    return Response.json({ error: 'File type not allowed' }, { status: 400 });
  }
  if (size > MAX_SIZE) {
    return Response.json({ error: 'File too large (max 10MB)' }, { status: 400 });
  }

  const key = `uploads/${randomUUID()}/${filename}`;

  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET!,
      Key: key,
      ContentType: contentType,
      ContentLength: size,
    }),
    { expiresIn: 600 } // 10 minutes
  );

  return Response.json({ url, key });
}
```

### 2. Client Upload with Progress (Presigned URL)

```typescript
async function uploadWithProgress(
  file: File,
  onProgress: (percent: number) => void,
  signal?: AbortSignal
): Promise<string> {
  // Step 1: Get presigned URL from our API
  const { url, key } = await fetch('/api/upload', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type,
      size: file.size,
    }),
  }).then((r) => r.json());

  // Step 2: Upload directly to S3/R2 with progress
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', url);
    xhr.setRequestHeader('Content-Type', file.type);

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    };

    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) resolve(key);
      else reject(new Error(`Upload failed: ${xhr.status}`));
    };

    xhr.onerror = () => reject(new Error('Upload network error'));

    if (signal) {
      signal.addEventListener('abort', () => xhr.abort());
    }

    xhr.send(file);
  });
}
```

### 3. React Dropzone Component

```tsx
'use client';
import { useCallback, useState } from 'react';
import { useDropzone, type FileRejection } from 'react-dropzone';

interface UploadZoneProps {
  onUploadComplete: (key: string) => void;
  maxSize?: number;
  accept?: Record<string, string[]>;
}

export function UploadZone({
  onUploadComplete,
  maxSize = 10 * 1024 * 1024,
  accept = { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] },
}: UploadZoneProps) {
  const [progress, setProgress] = useState<number | null>(null);
  const [error, setError] = useState<string | null>(null);

  const onDrop = useCallback(async (accepted: File[], rejected: FileRejection[]) => {
    setError(null);

    if (rejected.length > 0) {
      setError(rejected[0].errors[0].message);
      return;
    }

    const file = accepted[0];
    if (!file) return;

    try {
      setProgress(0);
      const key = await uploadWithProgress(file, setProgress);
      setProgress(100);
      onUploadComplete(key);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Upload failed');
    } finally {
      setTimeout(() => setProgress(null), 2000);
    }
  }, [onUploadComplete]);

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    maxSize,
    accept,
    maxFiles: 1,
  });

  return (
    <div
      {...getRootProps()}
      className={`p-6 rounded-xl border-2 border-dashed cursor-pointer
                  transition-all duration-200
                  ${isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}
                  focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500`}
    >
      <input {...getInputProps()} />
      {progress !== null ? (
        <div>
          <div className="h-2 bg-gray-200 rounded-full overflow-hidden">
            <div
              className="h-full bg-blue-600 transition-all duration-200"
              style={{ width: `${progress}%` }}
            />
          </div>
          <p className="mt-2 text-sm text-gray-600 text-center">{progress}%</p>
        </div>
      ) : (
        <p className="text-center text-gray-600">
          {isDragActive ? 'Drop the file here' : 'Drag and drop a file, or click to select'}
        </p>
      )}
      {error && <p className="mt-2 text-sm text-red-600 text-center">{error}</p>}
    </div>
  );
}
```

### 4. Next.js Route Handler (multipart/form-data)

```typescript
// app/api/upload-direct/route.ts -- for small files (< 10MB)
export async function POST(req: Request) {
  const formData = await req.formData();
  const file = formData.get('file') as File | null;

  if (!file) {
    return Response.json({ error: 'No file provided' }, { status: 400 });
  }

  // Validate MIME type by reading magic bytes (not just extension)
  const buffer = Buffer.from(await file.arrayBuffer());
  const mimeType = detectMimeType(buffer); // Use `file-type` package

  if (!ALLOWED_TYPES.includes(mimeType)) {
    return Response.json({ error: 'Invalid file type' }, { status: 400 });
  }

  // Upload to S3
  await s3.send(new PutObjectCommand({
    Bucket: process.env.S3_BUCKET!,
    Key: `uploads/${randomUUID()}/${file.name}`,
    Body: buffer,
    ContentType: mimeType,
  }));

  return Response.json({ success: true });
}
```

### 5. Client-Side Validation

```typescript
interface ValidationRule {
  maxSize: number;
  allowedTypes: string[];
  maxDimensions?: { width: number; height: number };
}

async function validateFile(file: File, rules: ValidationRule): Promise<string | null> {
  if (file.size > rules.maxSize) {
    return `File too large. Max ${(rules.maxSize / 1024 / 1024).toFixed(0)}MB.`;
  }

  if (!rules.allowedTypes.includes(file.type)) {
    return `File type ${file.type} not allowed.`;
  }

  // Image dimension check
  if (rules.maxDimensions && file.type.startsWith('image/')) {
    const dimensions = await getImageDimensions(file);
    if (dimensions.width > rules.maxDimensions.width || dimensions.height > rules.maxDimensions.height) {
      return `Image too large. Max ${rules.maxDimensions.width}x${rules.maxDimensions.height}px.`;
    }
  }

  return null; // Valid
}

function getImageDimensions(file: File): Promise<{ width: number; height: number }> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve({ width: img.width, height: img.height });
    img.onerror = reject;
    img.src = URL.createObjectURL(file);
  });
}
```

## Best Practices

1. **Use presigned URLs** for files > 5MB -- avoids proxying bytes through your server
2. **Validate on both client and server** -- client for UX, server for security
3. **Check magic bytes**, not just file extension -- extensions can be spoofed
4. **Generate unique keys** with UUID prefixes to prevent collisions and enumeration
5. **Set `Content-Disposition`** for downloadable files: `attachment; filename="original.pdf"`
6. **Limit upload size** at the infrastructure level too (Nginx `client_max_body_size`, Vercel 4.5MB body limit)
7. **Use abort controllers** to let users cancel in-progress uploads

## Common Pitfalls

| Pitfall | Impact | Fix |
|---------|--------|-----|
| No server-side validation | Malicious file uploads | Validate MIME type via magic bytes on server |
| Trusting `Content-Type` header | Attackers can spoof any type | Use `file-type` package to detect from bytes |
| Presigned URL without size limit | Attacker uploads huge files | Set `ContentLength` condition on presigned URL |
| No progress indicator | Users think upload is frozen | Use XMLHttpRequest `upload.onprogress` |
| Vercel 4.5MB body limit | Large uploads fail silently | Use presigned URLs for anything > 4MB |
| Sequential multi-file uploads | Slow UX | Upload files in parallel with `Promise.all` |

Related Skills

UploadThing

39
from InugamiDev/ultrathink-oss

> Type-safe file uploads for Next.js/React — no S3 config, built-in validation, instant CDN.

performance-profiler

39
from InugamiDev/ultrathink-oss

Profile, benchmark, and identify performance bottlenecks in applications — CPU, memory, network, rendering, and database query performance

ultrathink

39
from InugamiDev/ultrathink-oss

UltraThink Workflow OS — 4-layer skill mesh with persistent memory and privacy hooks for complex engineering tasks. Routes prompts through intent detection to activate the right domain skills automatically.

ultrathink_review

39
from InugamiDev/ultrathink-oss

Multi-pass code review powered by UltraThink's quality gate — checks correctness, security (OWASP), performance, readability, and project conventions in a single structured pass.

ultrathink_memory

39
from InugamiDev/ultrathink-oss

Persistent memory system for UltraThink — search, save, and recall project context, decisions, and patterns across sessions using Postgres-backed fuzzy search with synonym expansion.

ui-design

39
from InugamiDev/ultrathink-oss

Comprehensive UI design system: 230+ font pairings, 48 themes, 65 design systems, 23 design languages, 30 UX laws, 14 color systems, Swiss grid, Gestalt principles, Pencil.dev workflow. Inherits ui-ux-pro-max (99 UX rules) + impeccable-frontend-design (anti-AI-slop). Triggers on any design, UI, layout, typography, color, theme, or styling task.

Zod

39
from InugamiDev/ultrathink-oss

> TypeScript-first schema validation with static type inference.

webinar-registration-page

39
from InugamiDev/ultrathink-oss

Build a webinar or live event registration page as a self-contained HTML file with countdown timer, speaker bio, agenda, and registration form. Triggers on: "build a webinar registration page", "create a webinar sign-up page", "event registration landing page", "live training registration page", "workshop sign-up page", "create a webinar page", "build an event page", "free webinar landing page", "live demo registration page", "online event page", "create a registration page for my webinar", "build a training event page".

webhooks

39
from InugamiDev/ultrathink-oss

Webhook design patterns — delivery, retry with exponential backoff, HMAC signature verification, payload validation, idempotency keys

web-workers

39
from InugamiDev/ultrathink-oss

Offload heavy computation from the main thread using Web Workers, SharedWorkers, and Comlink — structured messaging, transferable objects, and off-main-thread architecture patterns

web-vitals

39
from InugamiDev/ultrathink-oss

Core Web Vitals monitoring (LCP, FID, CLS, INP, TTFB), measurement with web-vitals library, reporting to analytics, and optimization strategies for Next.js

web-components

39
from InugamiDev/ultrathink-oss

Native Web Components, custom elements API, Shadow DOM, HTML templates, slots, lifecycle callbacks, and framework-agnostic design patterns