Claude Code for UploadThing: Type-Safe File Uploads for Next.js — Claude Skills 360 Blog
Blog / Backend / Claude Code for UploadThing: Type-Safe File Uploads for Next.js
Backend

Claude Code for UploadThing: Type-Safe File Uploads for Next.js

Published: April 26, 2027
Read time: 6 min read
By: Claude Skills 360

UploadThing is a type-safe file upload solution built for Next.js — createUploadthing() creates a router builder. File route definitions include middleware for auth and onUploadComplete for post-upload logic. UploadButton and UploadDropzone are drop-in React components that connect to your router. generateReactHelpers(router) creates typed useUploadThing hooks for custom upload UI. File validation is declarative: image({ maxFileSize: "4MB", maxFileCount: 4 }). UploadThing stores files in their CDN and returns a public URL. UTApi from uploadthing/server lists and deletes files programmatically. Middleware receives req and can throw UPLOADTHING_ERROR to reject uploads. Upload state includes isUploading, progress, and fileUrls. Claude Code generates UploadThing router definitions, middleware auth guards, custom drag-and-drop components, and file management patterns.

CLAUDE.md for UploadThing

## UploadThing Stack
- Version: uploadthing >= 6.12, @uploadthing/react >= 6.12
- Router: f({ image: { maxFileSize: "4MB" } }).middleware(async ({ req }) => { ... }).onUploadComplete(async ({ metadata, file }) => { ... })
- Handler: createRouteHandler({ router }) in app/api/uploadthing/route.ts
- Components: <UploadButton endpoint="imageUploader" onClientUploadComplete={(files) => {}} />
- Hook: const { startUpload, isUploading } = useUploadThing("imageUploader")
- UTApi: const utapi = new UTApi(); await utapi.deleteFiles(fileKey)
- Env: UPLOADTHING_SECRET in .env — get from uploadthing.com dashboard

Server Router

// lib/uploadthing/router.ts — file upload route definitions
import { createUploadthing, type FileRouter } from "uploadthing/next"
import { UploadThingError } from "uploadthing/server"
import { auth } from "@clerk/nextjs/server"
import { db } from "@/lib/db"

const f = createUploadthing()

// Custom middleware helper
async function requireUser() {
  const { userId } = await auth()
  if (!userId) throw new UploadThingError("Unauthorized")
  return { userId }
}

export const uploadRouter = {
  // Profile avatar — single image, small size
  profileAvatar: f({ image: { maxFileSize: "2MB", maxFileCount: 1 } })
    .middleware(async () => requireUser())
    .onUploadComplete(async ({ metadata, file }) => {
      await db.user.update({
        where: { clerkId: metadata.userId },
        data: { avatarUrl: file.url },
      })
      return { url: file.url }
    }),

  // Product images — multiple, larger
  productImages: f({ image: { maxFileSize: "8MB", maxFileCount: 8 } })
    .middleware(async ({ req }) => {
      const { userId } = await requireUser()
      const productId = req.headers.get("x-product-id")
      if (!productId) throw new UploadThingError("Product ID required")

      // Verify user owns this product
      const product = await db.product.findFirst({
        where: { id: productId, userId },
      })
      if (!product) throw new UploadThingError("Product not found")

      return { userId, productId }
    })
    .onUploadComplete(async ({ metadata, file }) => {
      await db.productImage.create({
        data: {
          productId: metadata.productId,
          url: file.url,
          key: file.key,
          name: file.name,
          size: file.size,
        },
      })
      return { url: file.url, key: file.key }
    }),

  // Document upload — PDF/Word/Excel
  documents: f({
    pdf: { maxFileSize: "32MB" },
    "application/msword": { maxFileSize: "16MB" },
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { maxFileSize: "16MB" },
  })
    .middleware(requireUser)
    .onUploadComplete(async ({ metadata, file }) => {
      const doc = await db.document.create({
        data: {
          userId: metadata.userId,
          name: file.name,
          url: file.url,
          key: file.key,
          size: file.size,
          mimeType: file.type,
        },
      })
      return { id: doc.id, url: file.url }
    }),

  // Video upload — for course content
  courseVideo: f({ video: { maxFileSize: "512MB", maxFileCount: 1 } })
    .middleware(async ({ req }) => {
      const { userId } = await requireUser()
      // Check user has permission to upload course content
      const user = await db.user.findUnique({ where: { clerkId: userId } })
      if (user?.role !== "instructor" && user?.role !== "admin") {
        throw new UploadThingError("Instructor access required")
      }
      return { userId }
    })
    .onUploadComplete(async ({ metadata, file }) => {
      // Trigger processing job
      await videoQueue.add("process-video", { key: file.key, url: file.url, userId: metadata.userId })
      return { url: file.url, key: file.key }
    }),
} satisfies FileRouter

export type AppRouter = typeof uploadRouter
// app/api/uploadthing/route.ts — catch-all handler
import { createRouteHandler } from "uploadthing/next"
import { uploadRouter } from "@/lib/uploadthing/router"

export const { GET, POST } = createRouteHandler({ router: uploadRouter })

React Components

// components/upload/AvatarUploader.tsx — profile picture upload
"use client"
import { useState } from "react"
import { UploadButton } from "@uploadthing/react"
import type { AppRouter } from "@/lib/uploadthing/router"
import Image from "next/image"

interface AvatarUploaderProps {
  currentAvatarUrl?: string | null
  onUploadComplete?: (url: string) => void
}

export function AvatarUploader({ currentAvatarUrl, onUploadComplete }: AvatarUploaderProps) {
  const [avatarUrl, setAvatarUrl] = useState(currentAvatarUrl ?? null)

  return (
    <div className="flex flex-col items-center gap-4">
      <div className="relative size-24 rounded-full overflow-hidden bg-muted">
        {avatarUrl ? (
          <Image src={avatarUrl} alt="Avatar" fill className="object-cover" />
        ) : (
          <div className="size-full flex items-center justify-center text-3xl text-muted-foreground">
            👤
          </div>
        )}
      </div>

      <UploadButton<AppRouter, "profileAvatar">
        endpoint="profileAvatar"
        onClientUploadComplete={(res) => {
          const url = res[0].serverData?.url ?? res[0].url
          setAvatarUrl(url)
          onUploadComplete?.(url)
        }}
        onUploadError={(error) => {
          console.error("Upload error:", error.message)
        }}
        appearance={{
          button: "btn-outline text-sm",
          allowedContent: "text-xs text-muted-foreground",
        }}
        content={{
          button: "Change photo",
          allowedContent: "JPEG, PNG, WebP up to 2MB",
        }}
      />
    </div>
  )
}
// components/upload/ProductImageDropzone.tsx — multi-image upload
"use client"
import { useState, useCallback } from "react"
import { useUploadThing } from "@uploadthing/react"
import type { AppRouter } from "@/lib/uploadthing/router"

interface ProductImageDropzoneProps {
  productId: string
  onUploadComplete: (urls: string[]) => void
}

export function ProductImageDropzone({ productId, onUploadComplete }: ProductImageDropzoneProps) {
  const [files, setFiles] = useState<File[]>([])
  const [previews, setPreviews] = useState<string[]>([])
  const [progress, setProgress] = useState(0)

  const { startUpload, isUploading } = useUploadThing<AppRouter>("productImages", {
    headers: { "x-product-id": productId },
    onUploadProgress: (p) => setProgress(p),
    onClientUploadComplete: (res) => {
      const urls = res.map(f => f.url)
      onUploadComplete(urls)
      setFiles([])
      setPreviews([])
      setProgress(0)
    },
  })

  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault()
    const dropped = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith("image/"))
    setFiles(prev => [...prev, ...dropped].slice(0, 8))
    setPreviews(prev => [
      ...prev,
      ...dropped.map(f => URL.createObjectURL(f)),
    ].slice(0, 8))
  }, [])

  const removeFile = (index: number) => {
    setFiles(prev => prev.filter((_, i) => i !== index))
    URL.revokeObjectURL(previews[index])
    setPreviews(prev => prev.filter((_, i) => i !== index))
  }

  return (
    <div className="space-y-4">
      <div
        onDrop={handleDrop}
        onDragOver={e => e.preventDefault()}
        className="border-2 border-dashed border-muted-foreground/30 rounded-xl p-8 text-center hover:border-primary/50 transition-colors cursor-pointer"
      >
        <p className="text-muted-foreground">Drag images here or click to browse</p>
        <p className="text-xs text-muted-foreground mt-1">Up to 8 images, 8MB each</p>
      </div>

      {previews.length > 0 && (
        <div className="grid grid-cols-4 gap-2">
          {previews.map((src, i) => (
            <div key={i} className="relative aspect-square rounded-lg overflow-hidden group">
              <img src={src} alt="" className="w-full h-full object-cover" />
              <button
                onClick={() => removeFile(i)}
                className="absolute top-1 right-1 bg-black/50 text-white rounded-full size-5 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
              >
                ×
              </button>
            </div>
          ))}
        </div>
      )}

      {isUploading && (
        <div className="space-y-1">
          <div className="flex justify-between text-xs text-muted-foreground">
            <span>Uploading...</span>
            <span>{progress}%</span>
          </div>
          <div className="h-1.5 bg-muted rounded-full overflow-hidden">
            <div className="h-full bg-primary transition-all" style={{ width: `${progress}%` }} />
          </div>
        </div>
      )}

      <button
        onClick={() => files.length && startUpload(files)}
        disabled={files.length === 0 || isUploading}
        className="btn-primary w-full"
      >
        {isUploading ? `Uploading (${progress}%)...` : `Upload ${files.length} image${files.length !== 1 ? "s" : ""}`}
      </button>
    </div>
  )
}

For the Cloudinary Upload Widget alternative when a hosted upload widget with built-in image editing (crop, rotate, filter) and transformation CDN storage are preferred — Cloudinary’s widget provides a complete upload UX with no server code, though it’s tied to the Cloudinary ecosystem and pricing model, see the Cloudinary guide. For the AWS S3 pre-signed URL alternative when direct-to-S3 uploads with full control over storage, bucket policies, and zero per-file pricing beyond storage costs are preferred — generating pre-signed URLs and uploading from the browser directly avoids routing through your server, see the AWS S3 guide. The Claude Skills 360 bundle includes UploadThing skill sets covering routers, components, and file management. Start with the free tier to try file upload generation.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free