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:
- Headers – at minimum
Content-Disposition(which supplies the field name and optional filename) and optionallyContent-Type. - A blank line (CRLF).
- 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
fileevent gives you a readable stream (file) and metadata (info) containing the original filename, encoding, and MIME type. - The
fieldevent handles regular text fields. - When
closefires, 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:
- Call
CreateMultipartUploadto obtain an upload ID. - Return the upload ID and any required parts information to the client.
- Client uploads each part using
UploadPartwith presigned URLs (or directly via the SDK if credentials are available). - 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. - Limits –
maxFileSizeandmaxFileCountare 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:
- Constructing a
FormData and sending a multipart request from the browser. - Parsing
multipart/form-dataon the server withbusboy(orformidable). - Integrating with Cloudinary for quick, transformation‑ready media uploads.
- Generating presigned PUT URLs for AWS S3, enabling direct, secure uploads from the client.
- 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.