# File Upload to S3 in Next.js: The Production Pattern

> How to implement secure, scalable file uploads to AWS S3 from Next.js using presigned URLs — no files passing through your server.

URL: https://www.matthewswong.com/en/blog/file-upload-s3-nextjs-production/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2025-06-10
- Updated: 2025-06-10
- Category: Web Dev

The naive file upload implementation — multipart form to your Next.js API route, then re-upload to S3 — doesn't scale. Every file passes through your server, consuming memory, bandwidth, and execution time. The production pattern uses S3 presigned URLs: your server generates a temporary signed URL, the client uploads directly to S3, and your server only records the reference. I've implemented this pattern across multiple projects including an ERP document management system, and it handles everything from profile photos to 500MB PDF reports.

## The Presigned URL Architecture

The flow has three steps: (1) Client requests a presigned URL from your Next.js API route, sending only the file metadata (name, size, content type). (2) Your API route calls AWS SDK to generate a presigned PUT URL — a time-limited signed URL that allows the holder to upload one file to a specific S3 key. (3) Client uses the presigned URL to PUT the file directly to S3 from the browser — your server never touches the file bytes. After upload, the client notifies your API to record the S3 key in the database.

### Security: What the Server Must Validate

Presigned URLs are powerful but the server must validate before generating them: authenticate the user (who is requesting the upload?), validate file size limits (prevent 10GB uploads), validate allowed content types (only images for avatars, only PDFs for invoices), and generate a server-controlled S3 key (never let the client choose the file path — use uuid() + extension, not the original filename). Set the presigned URL expiry short — 60 seconds is plenty for the client to initiate the upload.

> From building an ERP document management system: use S3's server-side conditions in the presigned URL to enforce limits at the S3 level, not just in your API validation. Add ContentLengthRange conditions so even if your validation is bypassed, S3 will reject uploads outside the allowed size range. This defense-in-depth approach caught multiple attempted abuse cases in production where users modified the client-side validation.

## Implementation with Next.js App Router

The presigned URL generation lives in a Server Action or Route Handler. Install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner. Never expose AWS credentials to the client — all AWS SDK calls happen server-side. Configure the S3 client once with your region and credentials from environment variables. The PutObjectCommand combined with getSignedUrl() from s3-request-presigner generates the presigned URL. Set an appropriate expiry (60-300 seconds) and return only the URL to the client.

### Handling Large Files with Multipart Upload

Single presigned PUT requests work for files up to 5GB, but for files over ~100MB, use S3's multipart upload API. The client splits the file into 5-10MB chunks, requests a presigned URL for each part, uploads parts in parallel (for speed), then calls CompleteMultipartUpload. The ETag from each part's response is required to complete the upload — but CORS must expose the ETag header from S3, or your browser won't be able to read it. Add ETag to S3's ExposeHeaders in your CORS configuration.

> **CORS Configuration is Non-Negotiable**

> Direct browser-to-S3 uploads require a proper CORS configuration on your S3 bucket. Without it, the browser blocks the PUT request. The minimum CORS config: allow PUT (and OPTIONS for preflight) from your app's domain, expose ETag in headers for multipart upload tracking, and set a reasonable MaxAgeSeconds for preflight caching. Test CORS in staging before production — CORS errors are easy to diagnose locally but often appear differently across environments due to protocol differences (http vs https).

## Post-Upload Flow

After the client completes the S3 upload, it must notify your API to record the S3 key in your database. This is the confirmation step — without it, files uploaded to S3 have no database record and are effectively orphaned. I use a two-phase approach: generate a pending upload record in PostgreSQL when creating the presigned URL, then mark it confirmed when the client reports success. A background job sweeps for pending uploads older than 1 hour and deletes the corresponding S3 objects — cleaning up abandoned uploads.

## What I Use and Why

This presigned URL pattern is the standard approach for any production Next.js app. I use @aws-sdk/client-s3 v3 (the modular SDK — no need to import the entire AWS SDK), generate presigned URLs in Next.js Route Handlers (not Server Actions, since file upload UI typically involves client-side progress tracking), and store only the S3 key in PostgreSQL via Prisma. For serving files, I use CloudFront CDN in front of S3 for caching and performance — the S3 URL is never exposed directly to clients.

## Alternative: Cloudflare R2

AWS S3 egress costs add up for applications with heavy download traffic. Cloudflare R2 is S3-compatible (same SDK, same presigned URL pattern) with zero egress fees. For new projects, I evaluate R2 first — the only reason to choose S3 is if you're already deep in the AWS ecosystem or need specific S3 features R2 doesn't support yet. The presigned URL code is identical between S3 and R2; you just change the endpoint URL.

## Sources & Further Reading

- AWS Docs — Uploading objects with presigned URLs — https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- Coner Murphy — Using Presigned URLs in a Next.js App Router Project — https://conermurphy.com/blog/presigned-urls-nextjs-s3-upload/
- DEV Community — Upload large files to AWS S3 using Multipart upload and presigned URLs — https://dev.to/magpys/upload-large-files-to-aws-s3-using-multipart-upload-and-presigned-urls-4olo

## Source links

- https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- https://conermurphy.com/blog/presigned-urls-nextjs-s3-upload/
- https://dev.to/magpys/upload-large-files-to-aws-s3-using-multipart-upload-and-presigned-urls-4olo

## FAQ

### Why use presigned URLs instead of routing file uploads through the Next.js server?

Routing files through your server consumes memory, bandwidth, and execution time for every upload. With presigned URLs, the server generates a temporary signed URL and the client uploads directly to S3 — your server only handles a small metadata request and never touches the file bytes, making the pattern far more scalable.

### What server-side validation must happen before generating a presigned URL?

Before generating a presigned URL, the server must authenticate the user, validate the file size against allowed limits, and validate the content type (e.g. only images for avatars, only PDFs for invoices). The server must also generate the S3 key using uuid() plus the file extension — never let the client control the file path.

### How do S3-level ContentLengthRange conditions strengthen security beyond API validation?

Adding ContentLengthRange conditions to the presigned URL means S3 itself will reject uploads outside the allowed size range, even if a user bypasses your API-level validation. This defense-in-depth approach caught multiple attempted abuse cases in production where users modified client-side validation.

### When should you switch from a single presigned PUT to multipart upload?

Single presigned PUT requests work for files up to 5GB, but for files over roughly 100MB it is better to use S3's multipart upload API. The client splits the file into 5–10MB chunks, requests a presigned URL per part, uploads parts in parallel, then calls CompleteMultipartUpload. CORS must also expose the ETag header from S3 so the browser can read each part's response.

### Is Cloudflare R2 a viable alternative to AWS S3 for this pattern?

Yes. Cloudflare R2 is S3-compatible, so the same SDK and presigned URL code works with only the endpoint URL changed. R2 charges zero egress fees, making it attractive for applications with heavy download traffic. The post recommends evaluating R2 first for new projects, choosing S3 only when already deep in the AWS ecosystem or needing S3-specific features.

---

- HTML version: https://www.matthewswong.com/en/blog/file-upload-s3-nextjs-production/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/file-upload-s3-nextjs-production/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
