Skip to main content
Back to Blog
Tutorials
4 min read
November 24, 2024

How to Implement File Storage with S3 in Next.js

Set up file storage with AWS S3 or S3-compatible services in Next.js using presigned URLs for secure uploads and downloads.

Ryel Banfield

Founder & Lead Developer

Store and serve files securely using AWS S3 or an S3-compatible service like Cloudflare R2.

Step 1: Install Dependencies

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

Step 2: S3 Client Configuration

// lib/s3.ts
import { S3Client } from "@aws-sdk/client-s3";

export const s3Client = new S3Client({
  region: process.env.S3_REGION!,
  endpoint: process.env.S3_ENDPOINT, // For R2 or MinIO
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
  },
  forcePathStyle: true, // Required for some S3-compatible services
});

export const BUCKET_NAME = process.env.S3_BUCKET_NAME!;

Step 3: Upload Presigned URL API

// app/api/files/presigned-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 { s3Client, BUCKET_NAME } from "@/lib/s3";
import { randomUUID } from "crypto";
import { z } from "zod";

const schema = z.object({
  filename: z.string().min(1).max(255),
  contentType: z.string().min(1),
  size: z.number().max(10 * 1024 * 1024), // 10MB max
});

const ALLOWED_TYPES = [
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/gif",
  "application/pdf",
  "text/csv",
];

export async function POST(request: NextRequest) {
  const body = await request.json();
  const parsed = schema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
  }

  const { filename, contentType, size } = parsed.data;

  if (!ALLOWED_TYPES.includes(contentType)) {
    return NextResponse.json({ error: "File type not allowed" }, { 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(s3Client, command, {
    expiresIn: 300, // 5 minutes
  });

  return NextResponse.json({ presignedUrl, key });
}

Step 4: Download Presigned URL API

// app/api/files/presigned-download/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3Client, BUCKET_NAME } from "@/lib/s3";

export async function GET(request: NextRequest) {
  const key = new URL(request.url).searchParams.get("key");

  if (!key) {
    return NextResponse.json({ error: "key is required" }, { status: 400 });
  }

  // Validate key format to prevent path traversal
  if (key.includes("..") || !key.startsWith("uploads/")) {
    return NextResponse.json({ error: "Invalid key" }, { status: 400 });
  }

  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
  });

  const presignedUrl = await getSignedUrl(s3Client, command, {
    expiresIn: 3600, // 1 hour
  });

  return NextResponse.json({ url: presignedUrl });
}

Step 5: Delete File API

// app/api/files/[key]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
import { s3Client, BUCKET_NAME } from "@/lib/s3";

export async function DELETE(
  _request: NextRequest,
  { params }: { params: Promise<{ key: string }> }
) {
  const { key } = await params;
  const decodedKey = decodeURIComponent(key);

  if (decodedKey.includes("..") || !decodedKey.startsWith("uploads/")) {
    return NextResponse.json({ error: "Invalid key" }, { status: 400 });
  }

  await s3Client.send(
    new DeleteObjectCommand({
      Bucket: BUCKET_NAME,
      Key: decodedKey,
    })
  );

  return NextResponse.json({ success: true });
}

Step 6: List Files API

// app/api/files/route.ts
import { NextRequest, NextResponse } from "next/server";
import { ListObjectsV2Command } from "@aws-sdk/client-s3";
import { s3Client, BUCKET_NAME } from "@/lib/s3";

export async function GET(request: NextRequest) {
  const prefix = new URL(request.url).searchParams.get("prefix") ?? "uploads/";
  const continuationToken =
    new URL(request.url).searchParams.get("cursor") ?? undefined;

  const command = new ListObjectsV2Command({
    Bucket: BUCKET_NAME,
    Prefix: prefix,
    MaxKeys: 50,
    ContinuationToken: continuationToken,
  });

  const response = await s3Client.send(command);

  const files = (response.Contents ?? []).map((obj) => ({
    key: obj.Key,
    size: obj.Size,
    lastModified: obj.LastModified?.toISOString(),
  }));

  return NextResponse.json({
    files,
    nextCursor: response.NextContinuationToken ?? null,
    hasMore: response.IsTruncated ?? false,
  });
}

Step 7: Upload Component

// components/FileUpload.tsx
"use client";

import { useState, useRef } from "react";

interface UploadedFile {
  key: string;
  name: string;
  size: number;
  url?: string;
}

export function FileUpload({ onUpload }: { onUpload: (file: UploadedFile) => 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;

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

    try {
      // Get presigned URL
      const res = await fetch("/api/files/presigned-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 } = await res.json();

      // Upload directly to S3
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("PUT", presignedUrl);
        xhr.setRequestHeader("Content-Type", file.type);

        xhr.upload.onprogress = (event) => {
          if (event.lengthComputable) {
            setProgress(Math.round((event.loaded / event.total) * 100));
          }
        };

        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) {
            resolve();
          } else {
            reject(new Error("Upload failed"));
          }
        };

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

      onUpload({ key, name: file.name, size: file.size });
    } catch (err) {
      setError(err instanceof Error ? err.message : "Upload failed");
    } finally {
      setUploading(false);
      setProgress(0);
      if (inputRef.current) inputRef.current.value = "";
    }
  }

  return (
    <div className="space-y-2">
      <label className="block cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center transition hover:border-gray-400">
        <input
          ref={inputRef}
          type="file"
          onChange={handleUpload}
          disabled={uploading}
          className="hidden"
          accept="image/*,.pdf,.csv"
        />
        {uploading ? (
          <div>
            <p className="text-sm text-gray-600">Uploading... {progress}%</p>
            <div className="mx-auto mt-2 h-2 w-48 overflow-hidden rounded-full bg-gray-200">
              <div
                className="h-full bg-blue-600 transition-all"
                style={{ width: `${progress}%` }}
              />
            </div>
          </div>
        ) : (
          <div>
            <p className="text-sm font-medium text-gray-600">
              Click or drag to upload a file
            </p>
            <p className="mt-1 text-xs text-gray-400">
              Images, PDFs, or CSV up to 10MB
            </p>
          </div>
        )}
      </label>
      {error && <p className="text-sm text-red-600">{error}</p>}
    </div>
  );
}

Step 8: File List Component

// components/FileList.tsx
"use client";

import { useState, useEffect } from "react";

interface FileItem {
  key: string;
  size: number;
  lastModified: string;
}

function formatBytes(bytes: number) {
  if (bytes === 0) return "0 B";
  const k = 1024;
  const sizes = ["B", "KB", "MB", "GB"];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}

export function FileList() {
  const [files, setFiles] = useState<FileItem[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/files")
      .then((res) => res.json())
      .then((data) => setFiles(data.files))
      .finally(() => setLoading(false));
  }, []);

  async function handleDownload(key: string) {
    const res = await fetch(
      `/api/files/presigned-download?key=${encodeURIComponent(key)}`
    );
    const { url } = await res.json();
    window.open(url, "_blank");
  }

  async function handleDelete(key: string) {
    if (!confirm("Delete this file?")) return;
    await fetch(`/api/files/${encodeURIComponent(key)}`, {
      method: "DELETE",
    });
    setFiles((prev) => prev.filter((f) => f.key !== key));
  }

  if (loading) return <p className="text-sm text-gray-500">Loading files...</p>;
  if (files.length === 0) return <p className="text-sm text-gray-500">No files uploaded.</p>;

  return (
    <div className="divide-y rounded-lg border">
      {files.map((file) => (
        <div key={file.key} className="flex items-center justify-between px-4 py-3">
          <div className="min-w-0 flex-1">
            <p className="truncate text-sm font-medium">{file.key.split("/").pop()}</p>
            <p className="text-xs text-gray-500">
              {formatBytes(file.size)} — {new Date(file.lastModified).toLocaleDateString()}
            </p>
          </div>
          <div className="flex gap-2">
            <button
              onClick={() => handleDownload(file.key)}
              className="text-sm text-blue-600 hover:underline"
            >
              Download
            </button>
            <button
              onClick={() => handleDelete(file.key)}
              className="text-sm text-red-600 hover:underline"
            >
              Delete
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}

Need File Management for Your App?

We build secure file storage, media libraries, and document management systems. Contact us to get started.

S3file storagefile uploadAWSNext.jstutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles