Menu

15. File Uploads

Next.js Master Roadmap - IT Technology

This chapter explores the fundamentals of file uploads using the FormData API and multipart/form‑data encoding, then demonstrates how to integrate with Cloudinary, AWS S3, and UploadThing for secure, scalable storage in Next.js applications. You’ll learn low‑level handling with busboy/formidable, presigned URL generation, and high‑level helpers that simplify authentication, transformation, and metadata handling.

No MCQ questions available for this chapter.

15. File Uploads

Introduction

File uploads are a common requirement in modern web applications. Whether you are accepting profile pictures, documents, or media assets, understanding the underlying mechanics of multipart/form-data requests and how to leverage cloud storage services is essential for building reliable, performant, and secure features.

In this chapter we start with the browser‑side FormData API, examine the structure of a multipart request, and then move to server‑side parsing in a Next.js API route. After establishing the low‑level foundation, we explore three popular cloud‑storage integrations:

  • Cloudinary – a managed media platform that supports unsigned and signed uploads, on‑the‑fly transformations, and automatic optimisation.
  • AWS S3 – object storage that requires either a presigned PUT URL or a multipart upload via the AWS SDK.
  • UploadThing – a purpose‑built Next.js helper that abstracts away authentication, middleware, and upload completion callbacks.

By the end of the chapter you will be able to choose the appropriate strategy for your project, implement it correctly, and avoid common pitfalls such as exposing secrets, exceeding request size limits, or mishandling file streams.

Understanding FormData and multipart/form‑data

What is FormData?

The FormData interface provides a way to construct a set of key/value pairs representing form fields and their values, including files. It mirrors the data that an HTML form would submit with enctype="multipart/form-data".

Example: building a FormData object for a file upload

const form = new FormData(); form.append('file', fileInput.files[0]); // File object form.append('description', 'My upload'); fetch('/api/upload', { method: 'POST', body: form });

When the fetch call is made, the browser automatically sets the request’s Content-Type header to multipart/form-data; boundary=----<random> and encodes each part according to the multipart specification.

Structure of a multipart request

A multipart request consists of several parts, each delimited by a boundary string. Each part contains:

  1. Headers – at minimum Content-Disposition (which supplies the field name and optional filename) and optionally Content-Type.
  2. A blank line (CRLF).
  3. The part’s body – either text data or binary file data.

Example of a raw multipart payload (simplified):

------WebKitFormBoundaryABCD
Content-Disposition: form-data; name="file"; filename="image.png"
Content-Type: image/png

<binary PNG data>
------WebKitFormBoundaryABCD
Content-Disposition: form-data; name="description"

My upload
------WebKitFormBoundaryABCD--

Understanding this format helps when debugging server‑side parsers or when you need to manually construct a request (e.g., for testing with curl).

Low‑level multipart handling in Next.js

Next.js API routes disable the built‑in body parser by default for routes that need to handle raw streams. To process multipart/form-data you can use libraries such as busboy or formidable. Below is a complete example using busboy.

API route configuration

export const config = { api: { bodyParser: false } };

Setting bodyParser: false prevents Next.js from attempting to parse the incoming request as JSON, leaving the raw stream available for busboy.

Implementing the handler

import { promisify } from "util"; import busboy from "busboy"; export default async function handler(req, res) { if (req.method !== 'POST') { return res.status(405).end(); // Method Not Allowed } const bb = busboy({ headers: req.headers }); bb.on('file', (name, file, info) => { const { filename, encoding, mimeType } = info; console.log(`Uploaded file: ${filename} (${mimeType})`); // Example: pipe the file stream to a local disk or cloud storage // const writeStream = fs.createWriteStream(`/tmp/${filename}`); // file.pipe(writeStream); }); bb.on('field', (name, val, info) => { console.log(`Field [${name}]: value: ${val}`); }); bb.on('close', () => { res.status(200).json({ uploaded: true }); }); // Pipe the incoming request into busboy req.pipe(bb); }

Key points:

  • The file event gives you a readable stream (file) and metadata (info) containing the original filename, encoding, and MIME type.
  • The field event handles regular text fields.
  • When close fires, all parts have been processed and you can send a response.
  • Always remember to req.pipe(bb); otherwise the request will hang.

Cloudinary integration

Cloudinary offers a cloud‑based media management platform with features like automatic format selection, responsive breakpoints, and on‑the‑fly transformations. Uploads can be performed directly from the client using an unsigned preset (no secret required) or via a signed request for higher security.

Unsigned upload (client‑side)

An unsigned upload relies on an upload preset that you configure in the Cloudinary console. The preset defines allowed file types, tags, folder, and transformation rules.

const form = new FormData(); form.append('file', fileInput.files[0]); // File or Blob form.append('upload_preset', 'my_unsigned_preset'); fetch('https://api.cloudinary.com/v1_1/my-cloud/upload', { method: 'POST', body: form }) .then(response => response.json()) .then(data => { console.log('Secure URL:', data.secure_url); // Optionally store the URL in your database }) .catch(err => console.error(err));

Important: Never expose your Cloudinary API secret in client code. The unsigned preset is safe because it can be limited to specific actions and resources.

Signed upload (optional)

If you need tighter control (e.g., enforcing specific transformations or restricting to authenticated users), you can generate a signature on your server and send it with the request.

// Next.js API route to generate signature import { v2 as cloudinary } from 'cloudinary'; cloudinary.config({ cloud_name: process.env.CLOUDINARY_CLOUD_NAME, api_key: process.env.CLOUDINARY_API_KEY, api_secret: process.env.CLOUDINARY_API_SECRET, }); export default async function handler(req, res) { const timestamp = Math.round(Date.now() / 1000); const signature = cloudinary.utils.api_sign_request( { timestamp }, process.env.CLOUDINARY_API_SECRET ); res.status(200).json({ timestamp, signature }); }

The client then includes api_key, timestamp, and signature in the FormData before posting to Cloudinary’s upload endpoint.

Transformation URLs

Once an asset is stored, you can derive transformed URLs without re‑uploading. Cloudinary’s URL‑based transformations are immutable and cache‑friendly.

// Example: resize to 300×200, fill mode, and convert to WebP const base = 'https://res.cloudinary.com/my-cloud/image/upload'; const transformation = 'w_300,h_200,c_fill'; const publicId = 'sample.jpg'; const format = 'f_webp'; const url = `${base}/${transformation}/${publicId}.${format}`; console.log(url); // => https://res.cloudinary.com/my-cloud/image/upload/w_300,h_200,c_fill/sample.jpg

You can chain multiple transformations, add overlays, apply effects, or generate responsive srcset attributes automatically.

AWS S3 integration

Amazon S3 is an object‑storage service ideal for storing large numbers of files with high durability. Direct uploads from the browser are typically done via a presigned PUT URL, which grants temporary permission to put an object into a specific bucket/key.

Generating a presigned PUT URL (Next.js API route)

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const s3 = new S3Client({ region: process.env.AWS_REGION }); export default async function handler(req, res) { if (req.method !== 'POST') { return res.status(405).end(); } const { filename, contentType } = JSON.parse(req.body); const command = new PutObjectCommand({ Bucket: process.env.AWS_BUCKET, Key: `uploads/${filename}`, // you may want to add a UUID prefix ContentType: contentType, }); const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutes res.status(200).json({ url }); }

The client then performs a raw PUT request:

const response = await fetch('/api/s3-presign', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: 'photo.png', contentType: 'image/png' }) }); const { url } = await response.json(); await fetch(url, { method: 'PUT', body: file, // File or Blob headers: { 'Content-Type': file.type } });

Advantages:

  • No AWS credentials are exposed to the browser.
  • The URL is short‑lived (expiresIn) and scoped to a single object.
  • Uploads go straight to S3, reducing latency and server load.

Multipart upload via AWS SDK (for large files)

For files larger than 100 MB, S3’s multipart upload API improves reliability by splitting the file into parts, uploading them in parallel, and then completing the upload.

A typical server‑side flow:

  1. Call CreateMultipartUpload to obtain an upload ID.
  2. Return the upload ID and any required parts information to the client.
  3. Client uploads each part using UploadPart with presigned URLs (or directly via the SDK if credentials are available).
  4. After all parts are uploaded, the client calls CompleteMultipartUpload (or the server does it after receiving part ETags).

Because exposing long‑term AWS keys to the frontend is discouraged, you usually generate presigned URLs for each part on demand via an API route.

UploadThing integration

UploadThing is a Next.js‑focused library that abstracts away the boilerplate of authentication, file validation, and upload completion handling. It provides a type‑safe router on the server and a simple hook/function on the client.

Server‑side setup

Create a core module that exports the UploadThing router and the client helper.

// app/api/uploadthing/core.ts import { createUploadthing } from "uploadthing/server"; const f = createUploadthing(); export const ourUploadRouter = { // Define an endpoint for image uploads imageUploader: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } }) // Middleware runs before the upload is stored .middleware(({ req }) => { // Example: attach user ID from a custom header const userId = req.headers.get("x-user-id") ?? ""; return { userId }; // this becomes metadata }) // Called after the file is successfully stored .onUploadComplete(({ metadata, file }) => { console.log("Upload complete for user", metadata.userId); // You could update a database here return { uploadedBy: metadata.userId }; }), }; // Export the helper used in client components export const { uploadThing } = createUploadthing();

Key concepts:

  • File type validation – you can restrict to image, video, audio, or a custom MIME list.
  • LimitsmaxFileSize and maxFileCount are enforced automatically.
  • Middleware – receives the Next.js request object; you can read headers, cookies, or perform authentication and return metadata that will be available in onUploadComplete.
  • onUploadComplete – ideal for persisting URLs, triggering webhooks, or updating caches.

Client usage

import { uploadThing } from "@/app/api/uploadthing/core"; async function handleUpload(e: React.ChangeEvent) { const file = e.target.files[0]; if (!file) return; try { const result = await uploadThing({ file }); // result contains { url, ...metadata } console.log("Uploaded URL:", result.url); // e.g., https://utfs.io/a/... } catch (err) { console.error("Upload failed:", err); } } // In JSX return (
);

UploadThing automatically handles:

  • Creating a signed request to its own backend.
  • Streaming the file to temporary storage.
  • Moving the file to a permanent location (e.g., S3, Cloudinary, or a custom bucket) based on your configuration.
  • Returning a publicly accessible URL (or a signed URL if you prefer).

Security and best practices

Never expose secrets

API keys, AWS secret keys, and Cloudinary API secrets must remain on the server. Use environment variables (process.env) and ensure they are not included in the client‑side bundle.

Validate file type and size

Perform validation both on the client (for immediate feedback) and on the server (as a security gate). Check MIME type, file extension, and enforce size limits before processing.

Use HTTPS everywhere

All upload endpoints should be served over TLS to prevent man‑in‑the‑middle attacks and to keep presigned URLs confidential.

Limit request size

In Next.js you can configure the maximum body size for API routes via export const config = { api: { bodyParser: false, sizeLimit: '4mb' } }; (or handle it manually in your busboy middleware).

Store only what you need

After uploading, consider storing only the URL (or a reference) in your database, not the raw binary. This keeps your data store lightweight and makes backups faster.

Implement retry logic

Network interruptions can cause upload failures. Use exponential backoff and retry mechanisms, especially for multipart uploads to S3.

Summary

This chapter walked you through the full lifecycle of a file upload in a Next.js application:

  1. Constructing a FormData and sending a multipart request from the browser.
  2. Parsing multipart/form-data on the server with busboy (or formidable).
  3. Integrating with Cloudinary for quick, transformation‑ready media uploads.
  4. Generating presigned PUT URLs for AWS S3, enabling direct, secure uploads from the client.
  5. Using UploadThing to streamline authentication, validation, and post‑upload callbacks with minimal boilerplate.

By mastering both the low‑level mechanics and the high‑level services, you can choose the right tool for each scenario—whether you need instant image transformations, durable object storage, or a fully managed Next.js‑centric solution. Apply the security best practices outlined, and your file upload feature will be both robust and safe for production use.