Tutorials
|stacknotice.com
14 min left|
0%
|2,800 words
Tutorials

AWS S3 File Uploads in Next.js 15: Presigned URLs, Progress & CloudFront (2026)

Production S3 file uploads in Next.js 15 using presigned URLs. Progress tracking, file validation, multiple files, CloudFront CDN, and saving references to Postgres.

C
Carlos Oliva
Software Developer
June 11, 202614 min read
Share:
AWS S3 File Uploads in Next.js 15: Presigned URLs, Progress & CloudFront (2026)

UploadThing is the fastest way to add file uploads to a Next.js app. But at scale, you're paying per GB stored and per GB transferred. At 10k monthly active users uploading profile photos and documents, you're looking at $50–200/month for a service that wraps S3 — when S3 itself would cost $2–5/month for the same data.

This guide covers direct S3 uploads from Next.js using presigned URLs: no file touching your server, real progress tracking, file type and size validation, multiple file uploads, and a CloudFront CDN in front of your bucket. Production-ready code from the start.

How Presigned URLs Work

The naive approach — proxying uploads through your server — has two problems:

  1. Your serverless function has a body size limit (4.5MB on Vercel)
  2. Every byte uploaded consumes your server bandwidth and compute time

Presigned URLs flip this: your server generates a temporary, signed S3 URL with a 15-minute expiry. The client uploads directly to S3 using that URL. Your server never touches the file bytes.

Client → POST /api/upload/presign → Server generates presigned URL
Client → PUT [presigned S3 URL] → S3 (direct, no server involved)
Client → POST /api/upload/confirm → Server saves S3 key to database

Three requests: get the URL, upload the file, save the reference. Simple.

AWS Setup

First, create an S3 bucket and IAM user. In the AWS console:

  1. S3 → Create bucket → choose a region → Block all public access (we'll use CloudFront)
  2. IAM → Create user → attach this policy:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::your-bucket-name/*"
    }
  ]
}
  1. Generate access keys for the IAM user.

Configure S3 CORS — without this, browser uploads will be blocked:

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedOrigins": ["https://yourapp.com", "http://localhost:3000"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

Add your credentials to .env.local:

AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket-name
CLOUDFRONT_DOMAIN=https://d1234567890.cloudfront.net

Installing the AWS SDK

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Create the S3 client singleton:

// lib/s3.ts
import { S3Client } from '@aws-sdk/client-s3'
 
export const s3 = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
})
 
export const S3_BUCKET = process.env.S3_BUCKET_NAME!
export const CDN_DOMAIN = process.env.CLOUDFRONT_DOMAIN!
 
// Convert S3 key to CDN URL
export function s3KeyToCdnUrl(key: string): string {
  return `${CDN_DOMAIN}/${key}`
}

The Presign API Route

This route validates the request, generates the presigned URL, and returns it to the client:

// app/api/upload/presign/route.ts
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { s3, S3_BUCKET } from '@/lib/s3'
import { auth } from '@/lib/auth'
import { randomUUID } from 'crypto'
 
// Allowed file types and their MIME types
const ALLOWED_TYPES: Record<string, string> = {
  'image/jpeg': 'jpg',
  'image/png': 'png',
  'image/webp': 'webp',
  'image/gif': 'gif',
  'application/pdf': 'pdf',
}
 
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
 
interface PresignRequest {
  fileName: string
  fileType: string
  fileSize: number
  folder?: string // e.g. 'avatars', 'documents', 'posts'
}
 
export async function POST(request: Request) {
  const session = await auth()
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const body: PresignRequest = await request.json()
  const { fileName, fileType, fileSize, folder = 'uploads' } = body
 
  // Validate file type
  if (!ALLOWED_TYPES[fileType]) {
    return Response.json(
      { error: `File type not allowed. Accepted: ${Object.keys(ALLOWED_TYPES).join(', ')}` },
      { status: 400 }
    )
  }
 
  // Validate file size
  if (fileSize > MAX_FILE_SIZE) {
    return Response.json(
      { error: `File too large. Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB` },
      { status: 400 }
    )
  }
 
  // Build S3 key: folder/userId/uuid.ext
  const extension = ALLOWED_TYPES[fileType]
  const key = `${folder}/${session.userId}/${randomUUID()}.${extension}`
 
  // Generate presigned URL (expires in 15 minutes)
  const command = new PutObjectCommand({
    Bucket: S3_BUCKET,
    Key: key,
    ContentType: fileType,
    ContentLength: fileSize,
    // Enforce the content type — prevents uploading a different file type
    // using the same presigned URL
  })
 
  const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 900 })
 
  return Response.json({ presignedUrl, key })
}

The key pattern folder/userId/uuid.ext is important — it scopes files to the user (so they can't overwrite each other's files), and the UUID prevents filename collisions.

The Client-Side Upload Hook

Build a reusable hook that handles the full upload flow with progress tracking:

// hooks/useS3Upload.ts
import { useState, useCallback } from 'react'
 
interface UploadState {
  progress: number  // 0–100
  status: 'idle' | 'uploading' | 'success' | 'error'
  key: string | null
  error: string | null
}
 
interface UseS3UploadReturn {
  upload: (file: File, folder?: string) => Promise<string | null>
  state: UploadState
  reset: () => void
}
 
export function useS3Upload(): UseS3UploadReturn {
  const [state, setState] = useState<UploadState>({
    progress: 0,
    status: 'idle',
    key: null,
    error: null,
  })
 
  const reset = useCallback(() => {
    setState({ progress: 0, status: 'idle', key: null, error: null })
  }, [])
 
  const upload = useCallback(async (file: File, folder = 'uploads'): Promise<string | null> => {
    setState({ progress: 0, status: 'uploading', key: null, error: null })
 
    try {
      // Step 1: Get presigned URL from our API
      const presignResponse = await fetch('/api/upload/presign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          fileName: file.name,
          fileType: file.type,
          fileSize: file.size,
          folder,
        }),
      })
 
      if (!presignResponse.ok) {
        const err = await presignResponse.json()
        throw new Error(err.error ?? 'Failed to get upload URL')
      }
 
      const { presignedUrl, key } = await presignResponse.json()
 
      // Step 2: Upload directly to S3 with XMLHttpRequest for progress tracking
      // fetch() doesn't expose upload progress — XHR does
      await uploadWithProgress(file, presignedUrl, (progress) => {
        setState((prev) => ({ ...prev, progress }))
      })
 
      setState({ progress: 100, status: 'success', key, error: null })
      return key
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Upload failed'
      setState({ progress: 0, status: 'error', key: null, error: message })
      return null
    }
  }, [])
 
  return { upload, state, reset }
}
 
function uploadWithProgress(
  file: File,
  url: string,
  onProgress: (progress: number) => void
): Promise<void> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
 
    xhr.upload.addEventListener('progress', (event) => {
      if (event.lengthComputable) {
        const progress = Math.round((event.loaded / event.total) * 100)
        onProgress(progress)
      }
    })
 
    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve()
      } else {
        reject(new Error(`S3 upload failed with status ${xhr.status}`))
      }
    })
 
    xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
    xhr.addEventListener('abort', () => reject(new Error('Upload aborted')))
 
    xhr.open('PUT', url)
    xhr.setRequestHeader('Content-Type', file.type)
    xhr.send(file)
  })
}

The XMLHttpRequest detour is intentional — fetch() doesn't support upload progress events. XHR does.

File Upload Component with Progress

// components/FileUpload.tsx
'use client'
 
import { useRef, useState } from 'react'
import { useS3Upload } from '@/hooks/useS3Upload'
import { s3KeyToCdnUrl } from '@/lib/s3'
 
interface FileUploadProps {
  folder?: string
  accept?: string
  maxSizeMB?: number
  onUploadComplete?: (url: string, key: string) => void
  label?: string
}
 
export function FileUpload({
  folder = 'uploads',
  accept = 'image/*',
  maxSizeMB = 10,
  onUploadComplete,
  label = 'Upload file',
}: FileUploadProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const [preview, setPreview] = useState<string | null>(null)
  const { upload, state, reset } = useS3Upload()
 
  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return
 
    // Client-side size check before hitting the API
    if (file.size > maxSizeMB * 1024 * 1024) {
      alert(`File too large. Maximum size is ${maxSizeMB}MB`)
      return
    }
 
    // Show image preview immediately
    if (file.type.startsWith('image/')) {
      const reader = new FileReader()
      reader.onload = (e) => setPreview(e.target?.result as string)
      reader.readAsDataURL(file)
    }
 
    const key = await upload(file, folder)
    if (key && onUploadComplete) {
      onUploadComplete(s3KeyToCdnUrl(key), key)
    }
  }
 
  return (
    <div className="flex flex-col gap-3">
      {preview && (
        <img src={preview} alt="Preview" className="h-32 w-32 rounded-lg object-cover" />
      )}
 
      <button
        type="button"
        onClick={() => inputRef.current?.click()}
        disabled={state.status === 'uploading'}
        className="rounded-lg border-2 border-dashed border-gray-300 px-6 py-4 text-sm text-gray-500 hover:border-blue-400 hover:text-blue-500 disabled:opacity-50"
      >
        {state.status === 'uploading' ? 'Uploading...' : label}
      </button>
 
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        onChange={handleFileSelect}
        className="hidden"
      />
 
      {state.status === 'uploading' && (
        <div className="space-y-1">
          <div className="flex justify-between text-xs text-gray-500">
            <span>Uploading...</span>
            <span>{state.progress}%</span>
          </div>
          <div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-200">
            <div
              className="h-full rounded-full bg-blue-500 transition-all duration-200"
              style={{ width: `${state.progress}%` }}
            />
          </div>
        </div>
      )}
 
      {state.status === 'success' && (
        <p className="text-sm text-green-600">Upload complete</p>
      )}
 
      {state.status === 'error' && (
        <div className="flex items-center gap-2">
          <p className="text-sm text-red-600">{state.error}</p>
          <button
            onClick={reset}
            className="text-xs text-blue-500 underline"
          >
            Try again
          </button>
        </div>
      )}
    </div>
  )
}

Multiple File Upload

For uploading several files at once (a photo gallery, bulk document import), run uploads in parallel with a concurrency limit:

// hooks/useS3MultiUpload.ts
import { useState, useCallback } from 'react'
 
interface FileUploadResult {
  file: File
  key: string | null
  error: string | null
  progress: number
}
 
export function useS3MultiUpload() {
  const [uploads, setUploads] = useState<Map<string, FileUploadResult>>(new Map())
 
  const uploadFiles = useCallback(async (files: File[], folder = 'uploads') => {
    // Initialize state for all files
    const initial = new Map<string, FileUploadResult>()
    files.forEach((file) => {
      initial.set(file.name, { file, key: null, error: null, progress: 0 })
    })
    setUploads(initial)
 
    // Upload with max 3 concurrent uploads
    const concurrency = 3
    const results: string[] = []
 
    for (let i = 0; i < files.length; i += concurrency) {
      const batch = files.slice(i, i + concurrency)
 
      const batchResults = await Promise.all(
        batch.map(async (file) => {
          try {
            const presignResponse = await fetch('/api/upload/presign', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({
                fileName: file.name,
                fileType: file.type,
                fileSize: file.size,
                folder,
              }),
            })
 
            if (!presignResponse.ok) throw new Error('Failed to get upload URL')
            const { presignedUrl, key } = await presignResponse.json()
 
            await uploadWithProgress(file, presignedUrl, (progress) => {
              setUploads((prev) => {
                const next = new Map(prev)
                const current = next.get(file.name)!
                next.set(file.name, { ...current, progress })
                return next
              })
            })
 
            setUploads((prev) => {
              const next = new Map(prev)
              next.set(file.name, { file, key, error: null, progress: 100 })
              return next
            })
 
            return key as string
          } catch (err) {
            const error = err instanceof Error ? err.message : 'Upload failed'
            setUploads((prev) => {
              const next = new Map(prev)
              next.set(file.name, { file, key: null, error, progress: 0 })
              return next
            })
            return null
          }
        })
      )
 
      results.push(...(batchResults.filter(Boolean) as string[]))
    }
 
    return results
  }, [])
 
  const successCount = Array.from(uploads.values()).filter((u) => u.key !== null).length
  const errorCount = Array.from(uploads.values()).filter((u) => u.error !== null).length
  const totalProgress =
    uploads.size > 0
      ? Array.from(uploads.values()).reduce((sum, u) => sum + u.progress, 0) / uploads.size
      : 0
 
  return { uploadFiles, uploads, successCount, errorCount, totalProgress }
}

Saving File References to the Database

After a successful upload, save the S3 key to your database. Use Neon Postgres with Drizzle:

// app/api/upload/confirm/route.ts
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { userFiles } from '@/db/schema'
 
export async function POST(request: Request) {
  const session = await auth()
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const { key, fileName, fileType, fileSize } = await request.json()
 
  // Validate the key belongs to the current user
  // Key format: folder/userId/uuid.ext
  const keyParts = key.split('/')
  if (keyParts[1] !== session.userId) {
    return Response.json({ error: 'Forbidden' }, { status: 403 })
  }
 
  const [file] = await db
    .insert(userFiles)
    .values({
      userId: session.userId,
      s3Key: key,
      fileName,
      fileType,
      fileSize,
      url: s3KeyToCdnUrl(key),
    })
    .returning()
 
  return Response.json({ file })
}
// db/schema.ts (relevant table)
export const userFiles = pgTable('user_files', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  userId: text('user_id').notNull().references(() => users.id),
  s3Key: text('s3_key').notNull().unique(),
  fileName: text('file_name').notNull(),
  fileType: text('file_type').notNull(),
  fileSize: integer('file_size').notNull(),
  url: text('url').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

CloudFront CDN Setup

Serving files directly from S3 is slow for users outside your bucket's region. CloudFront caches files at ~400 edge locations globally — images load in 10–50ms instead of 200–500ms.

  1. CloudFront → Create distribution → Origin: your S3 bucket
  2. Restrict bucket access: Yes (CloudFront only, not public S3)
  3. Viewer protocol policy: Redirect HTTP to HTTPS
  4. Add a cache policy: Cache-Control headers from origin

Update the S3 bucket policy to only allow CloudFront:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-bucket-name/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DIST_ID"
        }
      }
    }
  ]
}

Now your s3KeyToCdnUrl function returns CloudFront URLs, files are cached globally, and the S3 bucket itself is private.

Deleting Files

When a user deletes a file, remove it from S3 and the database:

// app/api/upload/[key]/route.ts
import { DeleteObjectCommand } from '@aws-sdk/client-s3'
import { s3, S3_BUCKET } from '@/lib/s3'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { userFiles } from '@/db/schema'
import { and, eq } from 'drizzle-orm'
 
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ key: string }> }
) {
  const session = await auth()
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
 
  const { key } = await params
  const decodedKey = decodeURIComponent(key)
 
  // Verify ownership before deleting
  const file = await db.query.userFiles.findFirst({
    where: and(
      eq(userFiles.s3Key, decodedKey),
      eq(userFiles.userId, session.userId)
    ),
  })
 
  if (!file) return Response.json({ error: 'File not found' }, { status: 404 })
 
  // Delete from S3
  await s3.send(new DeleteObjectCommand({
    Bucket: S3_BUCKET,
    Key: decodedKey,
  }))
 
  // Delete from database
  await db.delete(userFiles).where(eq(userFiles.id, file.id))
 
  return Response.json({ success: true })
}

Environment Variables Checklist

# Required
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=your-secret
AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket
 
# Optional but recommended for production
CLOUDFRONT_DOMAIN=https://d1234.cloudfront.net

Never commit these. Add all five to your Vercel environment variables under Project → Settings → Environment Variables.

S3 vs UploadThing: When to Use Each

FactorS3 directUploadThing
Setup time~2 hours~20 minutes
Cost at scale$0.023/GB/month$0.10/GB + request fees
Progress trackingCustom (XHR)Built-in
File transformationsManual (Lambda)Built-in (image resize)
Background removalManualNot available
Self-hostedYesNo

Use UploadThing when you're moving fast and cost isn't a concern yet. Switch to direct S3 when your storage bill exceeds $20/month or when you need more control over where files live.

Summary

Direct S3 uploads with presigned URLs is the production pattern that most scaling apps end up at. The flow:

  1. Presign route validates file type/size and returns a temporary S3 URL — server never touches the bytes
  2. XHR upload from the browser directly to S3 with real progress events
  3. Confirm route saves the S3 key and CDN URL to your database after upload completes
  4. CloudFront in front of S3 for global CDN performance and private bucket security
  5. Delete route removes from both S3 and database with ownership check

The extra setup compared to UploadThing pays off once you're storing gigabytes of user data.

#aws#s3#nextjs#typescript#file-upload
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.