Node.js & Express

File Uploads & Storage

16 min Lesson 19 of 40

Introduction to File Uploads

Handling file uploads is a common requirement in web applications. Whether you're building a profile picture uploader, document management system, or media sharing platform, understanding how to securely and efficiently handle file uploads in Node.js is essential.

File Upload Considerations: Security (validate file types and sizes), storage location (disk, memory, cloud), performance (streaming vs buffering), and user experience (progress tracking, error handling).

Installing Multer

Multer is a Node.js middleware for handling multipart/form-data, primarily used for uploading files:

# Install Multer
npm install multer

# Install additional packages for cloud storage
npm install @aws-sdk/client-s3
npm install @aws-sdk/s3-request-presigner

# Install Sharp for image processing
npm install sharp

Basic File Upload Setup

Configure Multer with disk storage for handling single and multiple file uploads:

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const app = express();

// Ensure upload directory exists
const uploadDir = './uploads';
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// Configure disk storage
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, uploadDir); // Save files to uploads/ folder
  },
  filename: function (req, file, cb) {
    // Generate unique filename
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    const ext = path.extname(file.originalname);
    const name = path.basename(file.originalname, ext);
    cb(null, name + '-' + uniqueSuffix + ext);
  }
});

// Initialize multer with configuration
const upload = multer({
  storage: storage,
  limits: {
    fileSize: 5 * 1024 * 1024 // 5MB limit
  }
});

module.exports = upload;

Single File Upload

Handle uploading a single file to your server:

const express = require('express');
const upload = require('./config/multer');
const router = express.Router();

// Single file upload route
router.post('/upload-single', upload.single('avatar'), (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({
        success: false,
        message: 'No file uploaded'
      });
    }

    // File information
    const fileInfo = {
      originalName: req.file.originalname,
      fileName: req.file.filename,
      path: req.file.path,
      size: req.file.size,
      mimetype: req.file.mimetype
    };

    res.status(200).json({
      success: true,
      message: 'File uploaded successfully',
      file: fileInfo
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      message: 'Error uploading file',
      error: error.message
    });
  }
});

// Also handle form fields along with file
router.post('/upload-profile', upload.single('avatar'), (req, res) => {
  try {
    const { username, email } = req.body; // Access form fields
    const file = req.file; // Access uploaded file

    if (!file) {
      return res.status(400).json({ message: 'No file uploaded' });
    }

    // Process user data and file
    res.json({
      message: 'Profile updated successfully',
      user: { username, email },
      avatar: file.filename
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

module.exports = router;

Multiple File Uploads

Handle uploading multiple files at once:

const express = require('express');
const upload = require('./config/multer');
const router = express.Router();

// Upload multiple files with same field name
router.post('/upload-multiple', upload.array('photos', 10), (req, res) => {
  try {
    if (!req.files || req.files.length === 0) {
      return res.status(400).json({
        success: false,
        message: 'No files uploaded'
      });
    }

    // Process multiple files
    const filesInfo = req.files.map(file => ({
      originalName: file.originalname,
      fileName: file.filename,
      size: file.size,
      mimetype: file.mimetype
    }));

    res.status(200).json({
      success: true,
      message: `${req.files.length} files uploaded successfully`,
      files: filesInfo
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
});

// Upload files with different field names
router.post('/upload-mixed',
  upload.fields([
    { name: 'avatar', maxCount: 1 },
    { name: 'gallery', maxCount: 5 },
    { name: 'documents', maxCount: 3 }
  ]),
  (req, res) => {
    try {
      const uploadedFiles = {
        avatar: req.files.avatar || [],
        gallery: req.files.gallery || [],
        documents: req.files.documents || []
      };

      res.json({
        success: true,
        message: 'Files uploaded successfully',
        files: uploadedFiles
      });
    } catch (error) {
      res.status(500).json({ error: error.message });
    }
  }
);

module.exports = router;

File Validation

Implement robust file validation for size, type, and other constraints:

const multer = require('multer');
const path = require('path');

// File filter function
const fileFilter = (req, file, cb) => {
  // Allowed file extensions
  const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
  const ext = path.extname(file.originalname).toLowerCase();

  // Allowed MIME types
  const allowedMimeTypes = [
    'image/jpeg',
    'image/jpg',
    'image/png',
    'image/gif',
    'image/webp'
  ];

  // Check extension and MIME type
  if (allowedExtensions.includes(ext) && allowedMimeTypes.includes(file.mimetype)) {
    cb(null, true); // Accept file
  } else {
    cb(new Error(`Invalid file type. Only ${allowedExtensions.join(', ')} are allowed.`), false);
  }
};

// Configure multer with validation
const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB
    files: 10 // Maximum number of files
  }
});

// Error handling middleware
const handleMulterError = (err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    // Multer-specific errors
    if (err.code === 'LIMIT_FILE_SIZE') {
      return res.status(400).json({
        success: false,
        message: 'File too large. Maximum size is 5MB.'
      });
    }
    if (err.code === 'LIMIT_FILE_COUNT') {
      return res.status(400).json({
        success: false,
        message: 'Too many files. Maximum is 10 files.'
      });
    }
    if (err.code === 'LIMIT_UNEXPECTED_FILE') {
      return res.status(400).json({
        success: false,
        message: 'Unexpected field name in form data.'
      });
    }
  } else if (err) {
    // Custom errors (from fileFilter)
    return res.status(400).json({
      success: false,
      message: err.message
    });
  }
  next();
};

module.exports = { upload, handleMulterError };

Memory Storage

Use memory storage for temporary file processing without writing to disk:

const multer = require('multer');

// Configure memory storage
const memoryStorage = multer.memoryStorage();

const uploadToMemory = multer({
  storage: memoryStorage,
  limits: {
    fileSize: 2 * 1024 * 1024 // 2MB limit for memory storage
  }
});

// Route using memory storage
router.post('/upload-memory', uploadToMemory.single('image'), (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ message: 'No file uploaded' });
    }

    // File is in memory as Buffer
    const fileBuffer = req.file.buffer;
    const fileSize = req.file.size;
    const mimetype = req.file.mimetype;

    // Process file buffer (e.g., with Sharp for images)
    // Or upload directly to cloud storage

    res.json({
      message: 'File processed successfully',
      size: fileSize,
      type: mimetype
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});
When to Use Memory Storage: Use memory storage when you need to process files immediately (resize images, parse CSV) or upload to cloud storage without saving locally. Avoid for large files to prevent memory issues.

Cloud Storage with AWS S3

Upload files directly to AWS S3 for scalable cloud storage:

const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const multer = require('multer');
const path = require('path');
require('dotenv').config();

// Configure AWS S3 client
const s3Client = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
  }
});

// Use memory storage for S3 uploads
const uploadToMemory = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 10 * 1024 * 1024 } // 10MB
});

// Upload to S3 function
const uploadToS3 = async (file, folder = 'uploads') => {
  const fileName = `${folder}/${Date.now()}-${file.originalname}`;

  const params = {
    Bucket: process.env.AWS_S3_BUCKET,
    Key: fileName,
    Body: file.buffer,
    ContentType: file.mimetype,
    ACL: 'public-read' // Or 'private' for restricted access
  };

  try {
    const command = new PutObjectCommand(params);
    await s3Client.send(command);

    // Return public URL
    const fileUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${fileName}`;
    return fileUrl;
  } catch (error) {
    throw new Error(`S3 upload failed: ${error.message}`);
  }
};

// Route with S3 upload
router.post('/upload-s3', uploadToMemory.single('file'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ message: 'No file uploaded' });
    }

    // Upload to S3
    const fileUrl = await uploadToS3(req.file, 'user-uploads');

    res.json({
      success: true,
      message: 'File uploaded to S3 successfully',
      url: fileUrl
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
});

Serving Static Files

Make uploaded files accessible via URLs:

const express = require('express');
const path = require('path');
const app = express();

// Serve static files from uploads directory
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));

// Serve with custom headers and security
app.use('/uploads', (req, res, next) => {
  // Set security headers
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
  next();
}, express.static(path.join(__dirname, 'uploads')));

// Protected file serving (authentication required)
app.get('/files/:filename', authenticateUser, (req, res) => {
  const filename = req.params.filename;
  const filePath = path.join(__dirname, 'uploads', filename);

  // Check if file exists
  if (!fs.existsSync(filePath)) {
    return res.status(404).json({ message: 'File not found' });
  }

  // Send file with proper headers
  res.sendFile(filePath);
});

Image Processing with Sharp

Resize, compress, and transform images before saving:

const sharp = require('sharp');
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;

// Use memory storage for processing
const uploadToMemory = multer({
  storage: multer.memoryStorage(),
  fileFilter: (req, file, cb) => {
    if (file.mimetype.startsWith('image/')) {
      cb(null, true);
    } else {
      cb(new Error('Only image files are allowed'), false);
    }
  }
});

// Image processing function
const processImage = async (buffer, options = {}) => {
  const {
    width = 800,
    height = 600,
    quality = 80,
    format = 'jpeg'
  } = options;

  try {
    const processedImage = await sharp(buffer)
      .resize(width, height, {
        fit: 'inside',
        withoutEnlargement: true
      })
      .toFormat(format, { quality })
      .toBuffer();

    return processedImage;
  } catch (error) {
    throw new Error(`Image processing failed: ${error.message}`);
  }
};

// Route with image processing
router.post('/upload-image', uploadToMemory.single('image'), async (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ message: 'No image uploaded' });
    }

    // Process original image
    const processedBuffer = await processImage(req.file.buffer, {
      width: 1200,
      height: 800,
      quality: 85
    });

    // Create thumbnail
    const thumbnailBuffer = await processImage(req.file.buffer, {
      width: 300,
      height: 200,
      quality: 70
    });

    // Save processed images
    const filename = `${Date.now()}-${path.parse(req.file.originalname).name}`;
    const imagePath = `./uploads/${filename}.jpg`;
    const thumbnailPath = `./uploads/${filename}-thumb.jpg`;

    await fs.writeFile(imagePath, processedBuffer);
    await fs.writeFile(thumbnailPath, thumbnailBuffer);

    res.json({
      success: true,
      message: 'Image uploaded and processed',
      files: {
        original: `/uploads/${filename}.jpg`,
        thumbnail: `/uploads/${filename}-thumb.jpg`
      }
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
});
Security Warning: Always validate file types using both extension and MIME type checks. Rename uploaded files to prevent directory traversal attacks. Never trust user-provided filenames. Implement file size limits to prevent DoS attacks.

Practice Exercise

  1. Create a file upload API with the following endpoints:
    • POST /upload/profile-picture - Single image upload with validation
    • POST /upload/documents - Multiple file upload (PDFs, max 5 files)
    • GET /files/:filename - Serve uploaded files with authentication
  2. Implement file type validation (images: jpg/png/webp, documents: pdf/docx)
  3. Add image processing: resize profile pictures to 400x400px and create thumbnails
  4. Implement proper error handling for all upload scenarios
  5. Add file deletion endpoint with proper authorization checks
  6. Optional: Integrate AWS S3 for cloud storage instead of local disk