Chapter 23

File Uploads: Local Storage and Cloud Storage Solutions

Choosing an Upload Architecture

File upload seems straightforward, but the architecture decision has major implications for performance and cost. The most intuitive approach โ€” "client sends file to server, server saves to disk" โ€” works for single-machine prototypes but breaks down under serverless deployments (no persistent disk on Vercel), multi-instance setups (a file uploaded to instance A is invisible to instance B), and high traffic (large files consume precious server bandwidth and memory).

Production file upload approaches split into two directions: direct upload (the client uploads straight to cloud storage; the server only handles authorization) and proxy upload (the server receives the file and forwards it). Direct upload is more efficient and is the recommended production pattern. Proxy upload is simpler and is acceptable for small scale or where storage location has special requirements.

Handling multipart/form-data in Route Handlers

The most basic server-side file reception:

// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { auth } from '@/auth'

export async function POST(request: NextRequest) {
  const session = await auth()
  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const formData = await request.formData()
  const file = formData.get('file') as File | null

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

  // Server-side validation (client validation can be bypassed; server validation is mandatory)
  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
  if (!allowedTypes.includes(file.type)) {
    return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })
  }

  const maxSize = 5 * 1024 * 1024 // 5MB
  if (file.size > maxSize) {
    return NextResponse.json({ error: 'File too large' }, { status: 400 })
  }

  // Generate a safe filename โ€” never use the user-provided name
  const ext = path.extname(file.name).toLowerCase()
  const fileName = `${crypto.randomUUID()}${ext}`
  const uploadDir = path.join(process.cwd(), 'public', 'uploads')

  await mkdir(uploadDir, { recursive: true })

  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)
  await writeFile(path.join(uploadDir, fileName), buffer)

  return NextResponse.json({ url: `/uploads/${fileName}` })
}

Two security points deserve emphasis: never use user-provided filenames, because they enable path traversal attacks (../../etc/passwd) and name collisions โ€” always generate a random UUID-based name. MIME type validation must happen server-side, because the Content-Type header can be forged by the client. For stronger guarantees, check the file's magic bytes instead (covered below).

Vercel Blob: The Simplest Option

If you are deploying on Vercel, Vercel Blob is the zero-configuration cloud storage option:

npm install @vercel/blob
// app/api/upload/route.ts (Vercel Blob version)
import { put } from '@vercel/blob'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'

export async function POST(request: NextRequest) {
  const session = await auth()
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const formData = await request.formData()
  const file = formData.get('file') as File

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

  const blob = await put(`uploads/${crypto.randomUUID()}-${file.name}`, file, {
    access: 'public',
    contentType: file.type,
  })

  return NextResponse.json({ url: blob.url })
}

Vercel Blob files are served through a global CDN with permanent URLs. However, files still transit through your server โ€” not ideal for large files or high-concurrency uploads.

AWS S3 Presigned URLs: The Production Direct-Upload Pattern

A presigned URL is how large-file uploads should work: the server generates a temporarily authorized S3 URL, and the client uploads directly to S3, completely bypassing your server. Your server's bandwidth and compute are not involved in the actual file transfer.

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lib/s3.ts
import { S3Client } from '@aws-sdk/client-s3'

export const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
})
// app/api/upload/presign/route.ts
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { s3Client } from '@/lib/s3'

export async function POST(request: NextRequest) {
  const session = await auth()
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  const { fileName, fileType, fileSize } = await request.json()

  const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
  if (!allowedTypes.includes(fileType)) {
    return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })
  }

  const maxSize = 50 * 1024 * 1024 // 50MB
  if (fileSize > maxSize) {
    return NextResponse.json({ error: 'File too large' }, { status: 400 })
  }

  const key = `uploads/${session.user.id}/${crypto.randomUUID()}-${fileName}`

  const command = new PutObjectCommand({
    Bucket: process.env.AWS_S3_BUCKET!,
    Key: key,
    ContentType: fileType,
    ContentLength: fileSize,
    // Force Content-Disposition to prevent XSS from HTML file uploads
    ContentDisposition: 'attachment',
  })

  // Presigned URL is valid for 10 minutes
  const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 600 })

  return NextResponse.json({
    presignedUrl,
    key,
    publicUrl: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`,
  })
}

The client uses the presigned URL for a direct upload, tracking progress with XMLHttpRequest:

// hooks/useFileUpload.ts
'use client'

import { useState } from 'react'

interface UploadState {
  progress: number
  status: 'idle' | 'uploading' | 'success' | 'error'
  url: string | null
  error: string | null
}

export function useFileUpload() {
  const [state, setState] = useState<UploadState>({
    progress: 0,
    status: 'idle',
    url: null,
    error: null,
  })

  async function upload(file: File): Promise<string | null> {
    setState({ progress: 0, status: 'uploading', url: null, error: null })

    try {
      // 1. Get the presigned URL from the server
      const res = await fetch('/api/upload/presign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          fileName: file.name,
          fileType: file.type,
          fileSize: file.size,
        }),
      })

      if (!res.ok) {
        const { error } = await res.json()
        throw new Error(error)
      }

      const { presignedUrl, publicUrl } = await res.json()

      // 2. Upload directly to S3 using XHR (fetch does not support upload progress events)
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest()

        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            const progress = Math.round((e.loaded / e.total) * 100)
            setState(prev => ({ ...prev, progress }))
          }
        })

        xhr.addEventListener('load', () => {
          if (xhr.status === 200) resolve()
          else reject(new Error(`Upload failed: ${xhr.status}`))
        })

        xhr.addEventListener('error', () => reject(new Error('Network error')))

        xhr.open('PUT', presignedUrl)
        xhr.setRequestHeader('Content-Type', file.type)
        xhr.send(file)
      })

      setState({ progress: 100, status: 'success', url: publicUrl, error: null })
      return publicUrl
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Upload failed'
      setState(prev => ({ ...prev, status: 'error', error: message }))
      return null
    }
  }

  return { ...state, upload }
}

The fetch API does not yet support upload progress events, making XMLHttpRequest still necessary for this specific use case. It is one of the few scenarios where XHR remains the right tool.

Cloudflare R2: The Cost-Efficient S3-Compatible Option

R2 is Cloudflare's object storage with full S3 API compatibility. Its biggest advantage is zero egress fees โ€” reading data from R2 to the internet is free, whereas AWS S3 egress costs can become substantial at scale.

// lib/r2.ts โ€” same S3 client, different endpoint
import { S3Client } from '@aws-sdk/client-s3'

export const r2Client = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
})

The presigned URL logic is identical to S3 โ€” just substitute r2Client for s3Client. If your application has heavy file reads (images, videos, attachments), R2's zero egress pricing can save significant money.

Upload Component with Preview and Progress Bar

// components/FileUpload.tsx
'use client'

import { useState, useCallback } from 'react'
import { useFileUpload } from '@/hooks/useFileUpload'

interface FileUploadProps {
  onUploadComplete: (url: string) => void
  accept?: string
}

export function FileUpload({ onUploadComplete, accept = 'image/*' }: FileUploadProps) {
  const [preview, setPreview] = useState<string | null>(null)
  const { progress, status, error, upload } = useFileUpload()

  const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    // Client-side validation (UX improvement, not a security measure)
    if (file.size > 50 * 1024 * 1024) {
      alert('File must be under 50MB')
      return
    }

    // Image preview
    if (file.type.startsWith('image/')) {
      const reader = new FileReader()
      reader.onload = (e) => setPreview(e.target?.result as string)
      reader.readAsDataURL(file)
    }

    const url = await upload(file)
    if (url) onUploadComplete(url)
  }, [upload, onUploadComplete])

  return (
    <div className="space-y-4">
      <input
        type="file"
        accept={accept}
        onChange={handleFileChange}
        disabled={status === 'uploading'}
        className="block w-full text-sm"
      />

      {preview && (
        <img src={preview} alt="Preview" className="h-32 w-32 object-cover rounded" />
      )}

      {status === 'uploading' && (
        <div className="space-y-1">
          <div className="flex justify-between text-sm">
            <span>Uploading...</span>
            <span>{progress}%</span>
          </div>
          <div className="h-2 bg-gray-200 rounded">
            <div
              className="h-full bg-blue-500 rounded transition-all"
              style={{ width: `${progress}%` }}
            />
          </div>
        </div>
      )}

      {status === 'success' && (
        <p className="text-green-600 text-sm">Upload successful</p>
      )}

      {error && (
        <p className="text-red-500 text-sm">{error}</p>
      )}
    </div>
  )
}

Deep File Type Validation

The accept attribute and MIME type checks can both be bypassed by a determined attacker. More reliable validation reads the file's magic bytes โ€” characteristic byte sequences at the start of the actual file content, independent of the file extension:

// lib/validateFile.ts
export async function validateImageMagicBytes(file: File): Promise<boolean> {
  const buffer = await file.slice(0, 12).arrayBuffer()
  const bytes = new Uint8Array(buffer)

  // JPEG: FF D8 FF
  if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return true

  // PNG: 89 50 4E 47 0D 0A 1A 0A
  if (
    bytes[0] === 0x89 && bytes[1] === 0x50 &&
    bytes[2] === 0x4E && bytes[3] === 0x47
  ) return true

  // WebP: RIFF....WEBP
  if (
    bytes[0] === 0x52 && bytes[1] === 0x49 &&
    bytes[2] === 0x46 && bytes[3] === 0x46 &&
    bytes[8] === 0x57 && bytes[9] === 0x45 &&
    bytes[10] === 0x42 && bytes[11] === 0x50
  ) return true

  return false
}

Cleaning Up Orphaned Uploads

After a file is uploaded, the user might abandon the form, leaving the uploaded file permanently unreferenced. A periodic cleanup job prevents these orphans from accumulating storage costs:

// app/api/cron/cleanup-uploads/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { s3Client } from '@/lib/s3'

// Configure as a daily cron on Vercel
export async function GET(request: Request) {
  // Verify the cron request (prevent unauthorized triggers)
  const authHeader = request.headers.get('authorization')
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // Get all file URLs referenced in the database
  const referencedUrls = await prisma.user.findMany({
    where: { image: { not: null } },
    select: { image: true },
  })

  const referencedKeys = new Set(
    referencedUrls.map(u => u.image!.split('/').pop())
  )

  // List all files in S3
  const listCommand = new ListObjectsV2Command({
    Bucket: process.env.AWS_S3_BUCKET!,
    Prefix: 'uploads/',
  })
  const listed = await s3Client.send(listCommand)

  // Find files older than 24 hours that are not referenced
  const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
  const orphans = listed.Contents?.filter(obj =>
    obj.LastModified! < oneDayAgo &&
    !referencedKeys.has(obj.Key!.split('/').pop())
  ) ?? []

  if (orphans.length > 0) {
    await s3Client.send(new DeleteObjectsCommand({
      Bucket: process.env.AWS_S3_BUCKET!,
      Delete: {
        Objects: orphans.map(obj => ({ Key: obj.Key! })),
      },
    }))
  }

  return NextResponse.json({ deleted: orphans.length })
}

File upload is a standard requirement for full-stack applications. Local storage works for development and small deployments. Vercel Blob is ideal for rapid production launches. S3 presigned direct upload is the standard pattern for high-traffic production. R2 is the cost-optimized alternative. The right choice depends on your scale, deployment environment, and cost constraints โ€” but server-side validation (type checking, random filenames, authorization) is a non-negotiable requirement for all of them.

Rate this chapter
4.8  / 5  (6 ratings)

๐Ÿ’ฌ Comments