Uploading Files to S3
Uploading files to S3 is one of the most common use cases when building a backend. Instead of saving files to your server's disk (which doesn't scale and is lost on redeploy), you stream them directly to S3.
How it works
multer-s3 plugs into Multer as a custom storage engine. When a file is uploaded, Multer hands it off to multer-s3, which streams it directly to S3 — no temp file on disk.
Install Dependencies
- npm
- Yarn
- pnpm
- Bun
npm install multer multer-s3 @aws-sdk/client-s3 dotenv
yarn add multer multer-s3 @aws-sdk/client-s3 dotenv
pnpm add multer multer-s3 @aws-sdk/client-s3 dotenv
bun add multer multer-s3 @aws-sdk/client-s3 dotenv
Create the Upload Middleware
import multer from "multer";
import multerS3 from "multer-s3";
import s3 from "../config/s3.js";
import path from "path";
const upload = multer({
storage: multerS3({
s3,
bucket: process.env.AWS_S3_BUCKET_NAME,
// 'private' means the file cannot be accessed via a public URL
// Use presigned URLs instead (see the Presigned URLs doc)
acl: "private",
contentType: multerS3.AUTO_CONTENT_TYPE,
key: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const baseName = path.basename(file.originalname, ext)
.toLowerCase()
.replace(/\s+/g, "-");
const uniqueKey = `uploads/${baseName}-${Date.now()}${ext}`;
cb(null, uniqueKey);
},
}),
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB limit
},
fileFilter: (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Only images (JPEG, PNG, WebP) and PDFs are allowed"), false);
}
},
});
export default upload;
The key is the file's path inside your S3 bucket. Using a prefix like uploads/ keeps your bucket organized. Using Date.now() ensures keys are unique even if two users upload a file with the same name.
Create the Upload Route
import express from "express";
import upload from "../middlewares/uploadToS3.js";
const router = express.Router();
// Single file upload
router.post("/upload", upload.single("file"), (req, res) => {
if (!req.file) {
return res.status(400).json({ message: "No file provided" });
}
res.status(201).json({
message: "File uploaded successfully",
key: req.file.key, // S3 object key (e.g. uploads/photo-1712345678.jpg)
location: req.file.location, // Full S3 URL (only accessible if bucket is public)
size: req.file.size,
mimetype: req.file.mimetype,
});
});
// Multiple file upload (up to 5)
router.post("/upload-many", upload.array("files", 5), (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ message: "No files provided" });
}
const uploaded = req.files.map((file) => ({
key: file.key,
location: file.location,
size: file.size,
mimetype: file.mimetype,
}));
res.status(201).json({
message: `${uploaded.length} file(s) uploaded successfully`,
files: uploaded,
});
});
export default router;
Wire up in index.js
import express from "express";
import "dotenv/config";
import fileRouter from "./routes/files.js";
const app = express();
app.use(express.json());
app.use("/files", fileRouter);
app.listen(3000, () => console.log("Server running on port 3000"));
Test with Postman
- Set method to POST, URL to
http://localhost:3000/files/upload - Go to Body → form-data
- Add a key named
file, change type to File, and select a file - Hit Send
You should get a response like:
{
"message": "File uploaded successfully",
"key": "uploads/photo-1712345678.jpg",
"location": "https://your-bucket.s3.amazonaws.com/uploads/photo-1712345678.jpg",
"size": 204800,
"mimetype": "image/jpeg"
}
Handling Multer Errors
Multer throws errors for things like oversized files or wrong file type. Handle them in an error middleware:
import multer from "multer";
// Add after your routes
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
return res.status(400).json({ message: "File is too large. Max size is 10MB." });
}
return res.status(400).json({ message: err.message });
}
if (err.message.includes("Only images")) {
return res.status(400).json({ message: err.message });
}
res.status(500).json({ message: "Something went wrong" });
});
Storing the Key in Your Database
The key returned from S3 is what you store in your database — not the full URL. This lets you regenerate presigned URLs on demand without coupling your database to a specific region or bucket name.
import FileModel from "../models/file.js";
router.post("/upload", upload.single("file"), async (req, res) => {
if (!req.file) {
return res.status(400).json({ message: "No file provided" });
}
const file = await FileModel.create({
key: req.file.key,
originalName: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size,
});
res.status(201).json({ message: "Uploaded", file });
});
Key Takeaways
- Use
multer-s3to stream files directly to S3 — never save to disk on the server - Store the S3
keyin your database, not the full URL - Always validate file type and size before upload
- Keep buckets private; use presigned URLs to serve files (see next doc)