Skip to main content
Back to Blog
Tutorials
3 min read
November 9, 2024

How to Implement File Uploads with Presigned URLs in Next.js

Build secure file uploads with presigned URLs in Next.js. Upload directly to S3 without routing files through your server.

Ryel Banfield

Founder & Lead Developer

Presigned URLs let users upload files directly to cloud storage (S3, R2, GCS) without routing data through your server. This is faster, cheaper, and more scalable.

Step 1: Install AWS SDK

pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

Step 2: Configure S3 Client

// 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 BUCKET_NAME = process.env.S3_BUCKET_NAME!;

Step 3: Create the Presigned URL API Route

// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3, BUCKET_NAME } from "@/lib/s3";
import { randomUUID } from "crypto";

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

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

  // Validate file type
  if (!ALLOWED_TYPES.includes(contentType)) {
    return NextResponse.json(
      { error: "File type not allowed" },
      { status: 400 }
    );
  }

  // Validate file size
  if (size > MAX_SIZE) {
    return NextResponse.json(
      { error: "File too large. Max 10 MB." },
      { status: 400 }
    );
  }

  // Generate a unique key
  const ext = filename.split(".").pop();
  const key = `uploads/${randomUUID()}.${ext}`;

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    ContentType: contentType,
    ContentLength: size,
  });

  const presignedUrl = await getSignedUrl(s3, command, {
    expiresIn: 60, // URL expires in 60 seconds
  });

  return NextResponse.json({
    presignedUrl,
    key,
    url: `https://${BUCKET_NAME}.s3.amazonaws.com/${key}`,
  });
}

Step 4: Build the Upload Component

"use client";

import { useState, useRef } from "react";

interface UploadResult {
  url: string;
  key: string;
}

export function FileUpload({
  onUploadComplete,
}: {
  onUploadComplete: (result: UploadResult) => void;
}) {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState<string | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);

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

    setError(null);
    setUploading(true);
    setProgress(0);

    try {
      // 1. Get presigned URL from our API
      const res = await fetch("/api/upload", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        }),
      });

      if (!res.ok) {
        const data = await res.json();
        throw new Error(data.error || "Failed to get upload URL");
      }

      const { presignedUrl, key, url } = await res.json();

      // 2. Upload directly to S3
      await uploadWithProgress(presignedUrl, file, setProgress);

      // 3. Notify parent
      onUploadComplete({ url, key });
    } catch (err) {
      setError(err instanceof Error ? err.message : "Upload failed");
    } finally {
      setUploading(false);
      if (inputRef.current) inputRef.current.value = "";
    }
  }

  return (
    <div className="space-y-3">
      <label className="block">
        <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
          Upload File
        </span>
        <input
          ref={inputRef}
          type="file"
          onChange={handleUpload}
          accept="image/jpeg,image/png,image/webp,application/pdf"
          disabled={uploading}
          className="mt-1 block w-full text-sm file:mr-4 file:rounded-lg file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-sm file:font-medium file:text-blue-600 hover:file:bg-blue-100 disabled:opacity-50"
        />
      </label>

      {uploading && (
        <div className="space-y-1">
          <div className="h-2 overflow-hidden rounded-full bg-gray-200">
            <div
              className="h-full rounded-full bg-blue-600 transition-all"
              style={{ width: `${progress}%` }}
            />
          </div>
          <p className="text-xs text-gray-500">{Math.round(progress)}%</p>
        </div>
      )}

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

function uploadWithProgress(
  url: string,
  file: File,
  onProgress: (pct: number) => void
): Promise<void> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", url);
    xhr.setRequestHeader("Content-Type", file.type);

    xhr.upload.addEventListener("progress", (e) => {
      if (e.lengthComputable) {
        onProgress((e.loaded / e.total) * 100);
      }
    });

    xhr.addEventListener("load", () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve();
      } else {
        reject(new Error(`Upload failed with status ${xhr.status}`));
      }
    });

    xhr.addEventListener("error", () => reject(new Error("Upload failed")));
    xhr.send(file);
  });
}

Step 5: Drag-and-Drop Zone

"use client";

import { useState, useCallback } from "react";

export function DropZone({ onFile }: { onFile: (file: File) => void }) {
  const [dragging, setDragging] = useState(false);

  const handleDragOver = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    setDragging(true);
  }, []);

  const handleDragLeave = useCallback(() => {
    setDragging(false);
  }, []);

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      setDragging(false);
      const file = e.dataTransfer.files[0];
      if (file) onFile(file);
    },
    [onFile]
  );

  return (
    <div
      onDragOver={handleDragOver}
      onDragLeave={handleDragLeave}
      onDrop={handleDrop}
      className={`flex h-40 items-center justify-center rounded-xl border-2 border-dashed transition-colors ${
        dragging
          ? "border-blue-500 bg-blue-50 dark:bg-blue-900/10"
          : "border-gray-300 dark:border-gray-600"
      }`}
    >
      <p className="text-sm text-gray-500">
        {dragging ? "Drop file here" : "Drag and drop a file here, or click to browse"}
      </p>
    </div>
  );
}

Step 6: Image Preview

function ImagePreview({ file }: { file: File }) {
  const [preview, setPreview] = useState<string | null>(null);

  useEffect(() => {
    if (!file.type.startsWith("image/")) return;
    const url = URL.createObjectURL(file);
    setPreview(url);
    return () => URL.revokeObjectURL(url);
  }, [file]);

  if (!preview) return null;

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

Step 7: Multi-File Upload

async function uploadMultiple(files: File[]) {
  const results = await Promise.allSettled(
    files.map((file) => uploadSingleFile(file))
  );

  const successes = results
    .filter((r) => r.status === "fulfilled")
    .map((r) => r.value);

  const failures = results
    .filter((r) => r.status === "rejected")
    .map((r) => r.reason);

  return { successes, failures };
}

Step 8: Using with Cloudflare R2

Cloudflare R2 has an S3-compatible API. Just change the client config:

const s3 = new S3Client({
  region: "auto",
  endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

Everything else stays the same.

Need a Custom Upload System?

We build web applications with secure file upload systems, media management, and cloud storage integration. Get in touch to discuss your project.

file uploadpresigned URLsS3Next.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles