Lesson 33 of 5026 min read

File Uploads in Next.js with Local Storage, S3 and Cloudinary

Learn how to handle file uploads in Next.js, comparing local storage, AWS S3, and Cloudinary, and how to stream files efficiently.

Author: CodersNexus

File Uploads in Next.js with Local Storage, S3 and Cloudinary

Nearly every real application eventually needs to handle file uploads — a profile picture, a document, a product image. Where should that file actually be stored once a user uploads it? This turns out to be a more consequential decision than it first appears, especially for applications deployed to serverless environments, where storing files directly on the same server handling your application code often isn't viable at all.

This lesson compares three common approaches to storing uploaded files in a Next.js application — local disk storage, Amazon S3 (a popular cloud object storage service), and Cloudinary (a media-specific service with built-in image/video transformation) — and covers the practical mechanics of handling an upload request and, for larger files, streaming rather than loading an entire file into memory at once.

Learning Objectives

  • Understand why local file storage is often unsuitable for serverless-deployed Next.js applications.
  • Upload a file directly to AWS S3 from a Next.js Route Handler or Server Action.
  • Use Cloudinary for media uploads with built-in transformation capabilities.
  • Understand the presigned URL pattern for secure, direct-to-cloud uploads.
  • Recognize when streaming a file upload is preferable to loading it entirely into memory.

Core Definitions

  • Local storage (file system): Storing an uploaded file directly on the same server's disk that's running the application code.
  • AWS S3 (Simple Storage Service): A widely used cloud object storage service for storing and retrieving files (objects) at scale, independent of any specific application server.
  • Cloudinary: A cloud-based media management service providing storage alongside built-in image and video transformation, optimization, and delivery features.
  • Presigned URL: A temporary, securely generated URL that grants permission to upload (or download) a specific file directly to/from a cloud storage service, without exposing your actual storage credentials to the client.
  • Streaming: Processing a file's data incrementally, in smaller chunks, as it arrives, rather than loading the entire file into memory before doing anything with it.

Detailed Explanation

Storing an uploaded file directly on local disk — writing it to a folder on the same server running your Next.js application — seems like the simplest approach, and it can work for traditional, always-on servers with persistent storage. But it breaks down in serverless or edge-deployed environments (common for Next.js hosting), where each request might be handled by an entirely different, ephemeral server instance with no shared, persistent file system; a file saved during one request might simply not exist anymore, or on a different instance entirely, by the time a later request tries to read it. This makes local storage generally unsuitable for any Next.js application intended for real, serverless-friendly production deployment.

AWS S3 solves this by decoupling file storage from your application server entirely: files ('objects') are stored in a 'bucket', accessible via a stable URL, completely independent of which specific server instance handled the original upload request. Uploading directly from your Next.js backend (a Route Handler or Server Action) using the AWS SDK is straightforward for smaller files, but for larger uploads, a more scalable and secure pattern is the presigned URL: your backend generates a temporary, securely signed URL granting permission to upload directly to a specific location in your S3 bucket, and the browser uploads the file directly to S3 using that URL — bypassing your own server entirely for the actual (potentially large, slow) file transfer, while your backend never needs to see or handle the file's raw bytes at all, only generating the permission to upload it.

Cloudinary takes a different angle, specifically optimized for media (images and videos): beyond just storage, it provides built-in, on-the-fly transformation capabilities — resizing, cropping, format conversion, applying filters — simply by modifying parameters in the URL used to retrieve an image, without needing to store multiple pre-generated versions of the same file yourself. For applications primarily dealing with user-uploaded images or videos, Cloudinary's built-in transformation and CDN delivery can meaningfully reduce the amount of custom image-processing code you'd otherwise need to write and maintain yourself (echoing some of next/image's automatic optimization philosophy from Lesson 3.5, but for user-generated rather than developer-authored content).

For genuinely large file uploads — video files, large documents — streaming becomes important: rather than reading an entire file into your server's memory before processing or forwarding it (which can exhaust available memory for very large files, or for many simultaneous uploads), a streaming approach processes the file's data incrementally, in smaller chunks, as it arrives, forwarding each chunk onward (to S3, for example) without ever needing to hold the complete file in memory at once. Most modern upload patterns favor direct-to-cloud uploads (via presigned URLs) specifically to sidestep this memory concern for your own application server entirely, since the large file transfer happens directly between the browser and the cloud storage service rather than passing through your Next.js backend's memory at all.

Direct-to-Cloud Upload Flow Using a Presigned URL

{"heading":"Direct-to-Cloud Upload Flow Using a Presigned URL","description":"Visualize the presigned URL upload flow, avoiding your own server handling large file bytes:\n\n[Browser: user selects a file] --> [Request a presigned URL from your Next.js backend]\n --> [Backend generates a temporary, signed S3 upload URL, returns it to the browser]\n --> [Browser uploads the file DIRECTLY to S3 using that URL — bypassing your server entirely]\n --> [S3 confirms the upload] --> [Browser notifies your backend the upload is complete]\n --> [Backend saves just the file's resulting URL/reference in your database]\n\nYour Next.js server never touches the actual file bytes — only the small, lightweight presigned URL request/response."}

Next.js Practical Example

// app/api/upload-url/route.ts — generating a presigned S3 upload URL
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { NextResponse } from 'next/server';

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function POST(request: Request) {
  const { fileName, fileType } = await request.json();

  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: `uploads/${Date.now()}-${fileName}`,
    ContentType: fileType,
  });

  const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 60 }); // valid for 60 seconds

  return NextResponse.json({ uploadUrl });
}

// components/FileUploader.tsx — using the presigned URL from the browser
'use client';

async function uploadFile(file: File) {
  // 1. Ask our own backend for a presigned URL
  const res = await fetch('/api/upload-url', {
    method: 'POST',
    body: JSON.stringify({ fileName: file.name, fileType: file.type }),
  });
  const { uploadUrl } = await res.json();

  // 2. Upload the actual file DIRECTLY to S3, bypassing our own server
  await fetch(uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } });

  console.log('Upload complete!');
}

The Route Handler at /api/upload-url doesn't handle the actual file at all — it only generates a temporary, securely signed URL (valid for just 60 seconds) granting permission to upload one specific file to a specific location in an S3 bucket. FileUploader's uploadFile function first requests this presigned URL from our own backend, then makes a second, separate request directly to S3 using that URL to actually transfer the file's bytes — this second request never touches our own Next.js server at all, meaning even a very large file upload places zero memory or bandwidth burden on our own application, since the heavy lifting happens entirely between the browser and S3 directly.

How Different Products Handle File Uploads

  • Social media and content platforms handling large volumes of user-uploaded photos and videos almost universally use a cloud storage service (S3 or similar) with presigned URLs, rather than routing large file transfers through their own application servers.
  • E-commerce platforms use Cloudinary or a similar media service specifically for product images, taking advantage of on-the-fly resizing to serve appropriately sized thumbnails, listing images, and full-resolution zoom views all from a single uploaded original.
  • Document management and file-sharing SaaS products use S3 (or equivalent object storage) as their core file storage layer, given its scalability and reliability for handling potentially enormous file volumes across many customers.
  • Video-heavy platforms rely on presigned-URL-based direct uploads specifically because video files are often very large, making it impractical and resource-intensive to route them through an application server's own memory.
  • Internal, low-traffic admin tools sometimes still use simple local storage during early development or prototyping, deliberately migrating to cloud storage before any real production or serverless deployment.

Common Mistakes to Avoid

  • Using local disk storage for file uploads in an application intended for serverless or edge deployment, leading to unreliable or lost files in production.
  • Routing large file uploads through your own Next.js server's memory instead of using a presigned-URL-based direct-to-cloud upload pattern.
  • Exposing actual cloud storage credentials (like an AWS access key) directly to the client instead of generating a scoped, temporary presigned URL.
  • Forgetting to set a reasonably short expiration time on a presigned URL, leaving it valid and potentially exploitable for longer than necessary.
  • Manually re-implementing image resizing and transformation logic that a service like Cloudinary would otherwise handle automatically via URL parameters.

Interview Notes

  • Local disk storage is generally unsuitable for serverless-deployed Next.js applications due to ephemeral, non-shared file systems across instances.
  • AWS S3 provides scalable, application-server-independent object storage; Cloudinary adds built-in media transformation on top of similar storage capabilities.
  • A presigned URL grants temporary, secure permission to upload/download a specific file directly to/from cloud storage, without exposing real credentials to the client.
  • Streaming processes file data incrementally, avoiding memory exhaustion for large uploads.
  • Direct-to-cloud uploads via presigned URLs mean your own Next.js server's memory and bandwidth are never burdened by the actual file transfer.

Key Takeaways

  • Choosing where to store uploaded files is a consequential architectural decision, especially for applications deployed to serverless environments.
  • The presigned URL pattern elegantly solves secure, direct-to-cloud uploads without exposing credentials or burdening your own server.
  • Cloudinary's built-in media transformation can eliminate significant custom image-processing code for media-heavy applications.
  • Streaming and direct-to-cloud upload patterns work together to keep large file uploads from becoming a memory or performance bottleneck for your own application.

Summary

Handling file uploads in a Next.js application requires choosing where uploaded files actually live: local disk storage, while simple, is generally unsuitable for serverless-deployed applications since ephemeral server instances don't reliably share a persistent file system across requests. AWS S3 solves this by decoupling storage from any specific application server, and the presigned URL pattern — where your backend generates a temporary, securely signed upload URL for the browser to use directly — lets large files transfer straight from the browser to S3, bypassing your own server's memory and bandwidth entirely. Cloudinary offers a similar cloud storage foundation specifically optimized for media, adding built-in, on-the-fly image and video transformation through simple URL parameters. For genuinely large files, streaming — processing data incrementally in chunks rather than loading an entire file into memory — further protects your application from memory exhaustion, working naturally alongside direct-to-cloud upload patterns that already keep large transfers away from your own server's resources.