File Uploads

S3-compatible file uploads with presigned URLs.

Overview

Adds S3-compatible file uploads with presigned URLs and a drag-and-drop upload component.

Prerequisites

None.

Installation

npx ds-start init my-app --file-uploads
# or add to existing project
npx ds-start add file-uploads

What It Adds

  • lib/storage.ts — S3 client with presigned URL helpers
  • app/api/uploads/presign/route.ts — Presign API endpoint
  • components/file-upload.tsx — Drag-and-drop upload component

Compatible Providers

  • AWS S3
  • Cloudflare R2
  • MinIO
  • Backblaze B2

Environment Variables

VariableDescription
S3_BUCKETStorage bucket name
S3_REGIONBucket region
S3_ENDPOINTS3-compatible endpoint URL
S3_ACCESS_KEY_IDAccess key ID
S3_SECRET_ACCESS_KEYSecret access key

Usage

Storage helper

The storage module provides presigned URL generation for both uploads and downloads:

lib/storage.ts
import {
  GetObjectCommand,
  PutObjectCommand,
  S3Client,
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"

const s3 = new S3Client({
  region: process.env.S3_REGION ?? "us-east-1",
  endpoint: process.env.S3_ENDPOINT || undefined,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
  },
  forcePathStyle: true,
})

const bucket = process.env.S3_BUCKET ?? ""

export async function createPresignedUpload(options: {
  key: string
  contentType: string
  maxSize?: number
  expiresIn?: number
}): Promise<string> {
  const { key, contentType, maxSize, expiresIn = 600 } = options

  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: contentType,
    ...(maxSize ? { ContentLength: maxSize } : {}),
  })

  return getSignedUrl(s3, command, { expiresIn })
}

export async function createPresignedDownload(options: {
  key: string
  expiresIn?: number
}): Promise<string> {
  const { key, expiresIn = 3600 } = options

  const command = new GetObjectCommand({ Bucket: bucket, Key: key })
  return getSignedUrl(s3, command, { expiresIn })
}

export function generateUploadKey(filename: string): string {
  const now = new Date()
  const year = now.getFullYear()
  const month = String(now.getMonth() + 1).padStart(2, "0")
  const id = crypto.randomUUID().slice(0, 8)
  const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, "_")

  return `uploads/${year}/${month}/${id}-${sanitized}`
}

Presign API route

The API validates file type and size, then returns a presigned PUT URL and storage key:

app/api/uploads/presign/route.ts
import { createPresignedUpload, generateUploadKey } from "@/lib/storage"

const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const ALLOWED_CONTENT_TYPES = new Set([
  "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
  "application/pdf", "text/plain", "text/csv",
])

export async function POST(request: Request) {
  const body = await request.json()

  if (!body.filename || !body.contentType || typeof body.size !== "number") {
    return Response.json(
      { error: "Missing required fields: filename, contentType, size" },
      { status: 400 },
    )
  }

  if (body.size > MAX_FILE_SIZE) {
    return Response.json(
      { error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB` },
      { status: 400 },
    )
  }

  if (!ALLOWED_CONTENT_TYPES.has(body.contentType)) {
    return Response.json(
      { error: `Content type not allowed: ${body.contentType}` },
      { status: 400 },
    )
  }

  const key = generateUploadKey(body.filename)
  const url = await createPresignedUpload({
    key,
    contentType: body.contentType,
    maxSize: body.size,
  })

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

Client-side upload flow

The upload component handles presigning and direct-to-S3 upload with progress:

// 1. Request a presigned URL
const response = await fetch("/api/uploads/presign", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    filename: file.name,
    contentType: file.type,
    size: file.size,
  }),
})

const { url, key } = await response.json()

// 2. Upload directly to S3 with progress tracking
const xhr = new XMLHttpRequest()
xhr.open("PUT", url)
xhr.setRequestHeader("Content-Type", file.type)

xhr.upload.addEventListener("progress", (event) => {
  if (event.lengthComputable) {
    const percent = Math.round((event.loaded / event.total) * 100)
    console.log(`Upload progress: ${percent}%`)
  }
})

xhr.addEventListener("load", () => {
  if (xhr.status >= 200 && xhr.status < 300) {
    console.log(`Uploaded to key: ${key}`)
  }
})

xhr.send(file)

Using the FileUpload component

A ready-made drag-and-drop component is included:

import { FileUpload } from "@/components/file-upload"

export function MyUploadPage() {
  return (
    <FileUpload
      accept="image/*"
      maxSizeMB={10}
      multiple={false}
      onUploadComplete={(url) => console.log("Uploaded:", url)}
      onError={(error) => console.error("Upload failed:", error)}
    />
  )
}

On this page