Handling file uploads is a fundamental requirement for countless web applications, from social media platforms to content management systems. While it may seem straightforward to just save a file to a server's disk, this approach quickly reveals its limitations in a production environment. How do you scale storage? How do you handle backups? How do you deliver these files to users around the world with low latency?
This is where cloud-based media management services shine. Cloudinary is a market leader that provides an end-to-end solution for all your media needs, from storage and delivery to powerful on-the-fly transformations.
In this detailed guide, I'll walk you through building a robust file upload system in a Node.js, Express, and TypeScript application. I will create an API endpoint that can accept both a generic file (a PDF) and an image file (a cover image), validate them, and upload them directly to Cloudinary.
Prerequisites
To get the most out of this tutorial, you should have:
- A basic understanding of Node.js, Express, and TypeScript.
- A free Cloudinary account.
- Node.js and
npm(oryarn) installed on your machine.
Chapter 1: The Setup - Laying the Groundwork
First, let's get the project structure and configurations in place.
1.1: Project Initialization & Dependencies
In a new project directory, I'll start by installing the key dependencies:
-
cloudinary: The official Node.js SDK for interacting with the Cloudinary API. -
multer: A crucial middleware for Express that handlesmultipart/form-data, which is used for uploading files.
I'll install them, along with TypeScript types for a better development experience.
# Install runtime dependencies
npm i multer cloudinary
# Install development dependencies for TypeScript
npm i -D @types/multer
1.2: Securing Credentials with Environment Variables
You should never hardcode sensitive information like API keys in your source code. I'll use a .env file to store my Cloudinary credentials. Create a file named .env in your project's root directory.
You can find your Cloud Name, API Key, and API Secret in your Cloudinary Dashboard.
# .env
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
1.3: Configuring the Cloudinary SDK
Now, I'll create a dedicated configuration file that initializes the Cloudinary SDK with my credentials. This keeps the setup clean and centralized.
/src/config/cloudinary.ts
import { v2 as cloudinary } from "cloudinary";
// Assuming you have a utility to load env variables
import { CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET, CLOUDINARY_CLOUD_NAME } from "../utils/envLoader";
cloudinary.config({
cloud_name: CLOUDINARY_CLOUD_NAME,
api_key: CLOUDINARY_API_KEY,
api_secret: CLOUDINARY_API_SECRET,
secure: true, // Ensures that URLs are generated with HTTPS
timeout: 60000 // Optional: request timeout in milliseconds
});
export default cloudinary;
By exporting this configured instance, I can import it anywhere in my application to interact with the Cloudinary API.
Chapter 2: The Gatekeeper - Intercepting Files with Multer Middleware
By default, Express doesn't know how to handle file uploads sent as multipart/form-data. Multer is the middleware that bridges this gap. It parses the incoming request, extracts the files, and attaches them to the request object.
I will now create a sophisticated middleware that not only parses files but also validates them based on their purpose.
/src/middlewares/upload.middleware.ts
import multer from "multer";
import { ApiError } from "../utils/api-error"; // A custom error class
// 1. Choose a Storage Strategy
const storage = multer.memoryStorage();
// 2. Implement a Smart File Filter
const fileFilter = (field: string) => {
return (_: any, file: Express.Multer.File, cb: any) => {
if (field === "file") {
// Allow PDF only for the 'file' field
if (file.mimetype !== "application/pdf") {
return cb(new ApiError(400, 'Only PDF files are allowed'), false);
}
} else if (field === "coverImage") {
// Allow JPEG/PNG for the 'coverImage' field
if (!['image/jpeg', 'image/png'].includes(file.mimetype)) {
return cb(new ApiError(400, 'Only JPEG/PNG images are allowed'), false);
}
}
// Accept the file
cb(null, true);
}
}
// 3. Configure Multer for specific fields
export const uploadFields = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB file size limit
}).fields([
{ name: 'file', maxCount: 1 },
{ name: 'coverImage', maxCount: 1 },
]);
Let me break this down:
-
multer.memoryStorage(): This tells Multer to store the uploaded file as aBufferin RAM. This is highly efficient for this use case because I can stream this buffer directly to Cloudinary without ever writing it to the server's disk. -
fileFilterFunction: This is my custom validation logic. It's designed to check the file'smimetypeand reject any file that doesn't match the rules I've set for a specific field. If validation fails, I pass a customApiErrorto the callback; otherwise, I passnullandtrueto accept it. -
multer().fields([...]): This is the final configuration. It tells Multer to expect files on specific named fields (fileandcoverImage). I also set a global file size limit to prevent abuse.
Chapter 3: The Core Logic - The Service Layer and Cloudinary Upload
With the middleware ready to parse and validate files, it's time to write the logic to upload them. I believe in separating this business logic into a "service" layer.
3.1: A Promisified Upload Helper
The Cloudinary SDK's upload_stream method uses a callback pattern. To work cleanly with modern async/await syntax, I'll wrap it in a Promise. This creates a reusable and elegant helper function.
/src/services/book.service.ts (Helper Function)
import cloudinary from "../config/cloudinary";
function uploadToCloudinary(buffer: Buffer, options: any): Promise<any> {
return new Promise((resolve, reject) => {
const stream = cloudinary.uploader.upload_stream(options, (error, result) => {
if (error) return reject(error);
resolve(result);
});
stream.end(buffer);
});
}
This helper takes a file buffer and Cloudinary options, returning a promise that resolves with the upload result or rejects with an error.
3.2: Orchestrating the Uploads in the Service
Now I can use the helper function inside my main service method. This method will be responsible for uploading both files to Cloudinary and then creating a record in my database.
/src/services/book.service.ts (Main Method)
// ... (imports and the helper function from above)
export class BookService {
static async addBook(input: AddBookInput, file: any, coverImage: any) {
const { title, author, /*...other book data*/ } = input;
// 1. Upload file (PDF) to Cloudinary with 'raw' resource type
const fileResult = await uploadToCloudinary(file.buffer, { resource_type: 'raw', public_id: `books/files/${title}` });
// 2. Upload cover image to Cloudinary with 'image' resource type
const coverResult = await uploadToCloudinary(coverImage.buffer, { resource_type: 'image', public_id: `books/covers/${title}` });
// 3. Create book record in the database with the returned URLs
const book = await Book.create({
title,
author,
// ...other book data
coverImageUrl: coverResult.secure_url,
fileUrl: fileResult.secure_url
});
return book;
}
}
This is the heart of the operation. Notice two critical details in my approach:
-
resource_type: 'raw': When uploading the PDF, I tell Cloudinary to treat it as a generic "raw" file. This stores it as-is without trying to perform any image-specific processing. -
resource_type: 'image': For the cover image, I specify'image'. This unlocks all of Cloudinary's powerful image manipulation and optimization features for this asset. -
public_id: This is an optional but highly recommended parameter I use. It allows you to define a specific path and name for your file in Cloudinary, making your media library much more organized.
Chapter 4: Tying It All Together - The Controller
The final piece is the controller, which handles the HTTP request and response, acting as a bridge between the client and my service layer.
4.1: The API Endpoint
In your Express router file, you would use the uploadFields middleware right before your controller function.
/src/routes/book.routes.ts
import { Router } from "express";
import { BookController } from "../controllers/book.controller";
import { uploadFields } from "../middlewares/upload.middleware";
const router = Router();
router.post('/books', uploadFields, BookController.createBook);
export default router;
The request will flow through uploadFields first. If the files are valid, it will populate req.files and then pass control to BookController.createBook.
4.2: The Controller's Role
The controller's job is now very clean: to orchestrate the flow of data.
/src/controllers/book.controller.ts
// ... (imports for Request, Response, NextFunction, etc.)
export class BookController {
static async createBook(req: Request, res: Response, next: NextFunction) {
try {
// 1. Extract JSON data from the request body
let bookData = req.body;
// Handle cases where JSON data is stringified in a form field
if (typeof req.body.bookDetails === "string") {
bookData = JSON.parse(req.body.bookDetails);
}
// 2. Extract files populated by Multer
if (!req.files) throw new ApiError(400, 'File and CoverImage are required');
const files = req.files as { [fieldname: string]: Express.Multer.File[] };
const file = files.file?.[0];
const cover = files.coverImage?.[0];
if (!file || !cover) throw new ApiError(400, 'Both a book file and a cover image are required');
// 3. Pass the data to the service layer
const book = await BookService.addBook(bookData, file, cover);
// 4. Send the successful response
return res.status(201).json(new ApiResponse(201, { book }, "Book created successfully"));
} catch (error) {
next(error); // Pass errors to a centralized error handler
}
}
}
Conclusion & Next Steps
Congratulations! You have successfully built a clean, scalable, and robust file handling system. By following the process I laid out, you've used multer for server-side parsing and validation, and Cloudinary for cloud storage and delivery to create a professional-grade solution.
This architecture provides a clear separation of concerns:
-
Middleware (
multer): Handles raw request parsing and validation. - Controller: Manages HTTP flow and data extraction.
- Service: Contains the core business logic of interacting with external services and the database.
From here, you can expand this foundation. I suggest:
-
Implementing Deletion: Create a service method to delete assets from Cloudinary using
cloudinary.uploader.destroy()when a book record is removed from your database. - Using Transformations: Serve optimized images on your frontend by simply adding transformation parameters to the Cloudinary URL (e.g., resizing, cropping, adding watermarks).
-
Building the Frontend: Create a form on your client-side application that sends a
FormDataobject to this new API endpoint.
Top comments (0)