File Upload Basics

File uploads involve receiving files from clients, validating them, and storing them either locally or in cloud storage. This guide covers server-side handling with Express/Multer and client-side implementation in React.

Server-Side with Multer

Basic Setup

# Install multer
npm install multer

// server.js
const express = require('express');
const multer = require('multer');
const path = require('path');

const app = express();

// Basic memory storage
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});

// Single file upload
app.post('/upload', upload.single('file'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  console.log('File:', req.file);
  // {
  //   fieldname: 'file',
  //   originalname: 'photo.jpg',
  //   encoding: '7bit',
  //   mimetype: 'image/jpeg',
  //   buffer: ,
  //   size: 12345
  // }

  res.json({
    message: 'File uploaded successfully',
    filename: req.file.originalname,
    size: req.file.size,
  });
});

Disk Storage Configuration

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // Dynamic destination based on file type
    let uploadPath = 'uploads/';
    if (file.mimetype.startsWith('image/')) {
      uploadPath += 'images/';
    } else if (file.mimetype === 'application/pdf') {
      uploadPath += 'documents/';
    }
    cb(null, uploadPath);
  },
  filename: (req, file, cb) => {
    // Generate unique filename
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    const ext = path.extname(file.originalname);
    cb(null, file.fieldname + '-' + uniqueSuffix + ext);
  },
});

const upload = multer({
  storage,
  limits: {
    fileSize: 10 * 1024 * 1024, // 10MB
    files: 5, // Max 5 files
  },
  fileFilter: (req, file, cb) => {
    // Allowed file types
    const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];

    if (allowedTypes.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error('Invalid file type'), false);
    }
  },
});

// Multiple file uploads
app.post('/upload/multiple', upload.array('files', 5), (req, res) => {
  res.json({
    message: `${req.files.length} files uploaded`,
    files: req.files.map(f => ({
      filename: f.filename,
      path: f.path,
      size: f.size,
    })),
  });
});

// Multiple fields
app.post('/upload/fields', upload.fields([
  { name: 'avatar', maxCount: 1 },
  { name: 'documents', maxCount: 5 },
]), (req, res) => {
  res.json({
    avatar: req.files['avatar']?.[0],
    documents: req.files['documents'],
  });
});

File Validation

const fileFilter = (req, file, cb) => {
  // Check file extension
  const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.pdf'];
  const ext = path.extname(file.originalname).toLowerCase();

  if (!allowedExtensions.includes(ext)) {
    return cb(new Error(`File type ${ext} not allowed`), false);
  }

  // Check MIME type
  const allowedMimes = [
    'image/jpeg',
    'image/png',
    'image/gif',
    'application/pdf',
  ];

  if (!allowedMimes.includes(file.mimetype)) {
    return cb(new Error('Invalid MIME type'), false);
  }

  cb(null, true);
};

// Validate file content (magic bytes)
const validateFileContent = async (buffer, mimeType) => {
  const fileType = await import('file-type');
  const detected = await fileType.fileTypeFromBuffer(buffer);

  if (!detected || detected.mime !== mimeType) {
    throw new Error('File content does not match declared type');
  }

  return true;
};

// Use in route
app.post('/upload/secure', upload.single('file'), async (req, res) => {
  try {
    await validateFileContent(req.file.buffer, req.file.mimetype);

    // Process file...
    res.json({ success: true });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Error Handling

// Multer error handling middleware
const handleMulterError = (err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    switch (err.code) {
      case 'LIMIT_FILE_SIZE':
        return res.status(400).json({
          error: 'File too large',
          maxSize: '10MB',
        });
      case 'LIMIT_FILE_COUNT':
        return res.status(400).json({
          error: 'Too many files',
          maxFiles: 5,
        });
      case 'LIMIT_UNEXPECTED_FILE':
        return res.status(400).json({
          error: 'Unexpected field name',
        });
      default:
        return res.status(400).json({
          error: err.message,
        });
    }
  }

  if (err) {
    return res.status(400).json({ error: err.message });
  }

  next();
};

// Apply middleware
app.post('/upload',
  upload.single('file'),
  handleMulterError,
  (req, res) => {
    // Handle successful upload
  }
);

Cloud Storage with AWS S3

Setup and Configuration

# Install AWS SDK
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

// config/s3.js
const { S3Client } = require('@aws-sdk/client-s3');

const s3Client = new S3Client({
  region: process.env.AWS_REGION || 'us-east-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
  },
});

module.exports = s3Client;

Upload to S3

const { PutObjectCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { v4: uuidv4 } = require('uuid');
const s3Client = require('./config/s3');

const BUCKET_NAME = process.env.AWS_S3_BUCKET;

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

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    Body: file.buffer,
    ContentType: file.mimetype,
    // Make public (optional)
    // ACL: 'public-read',
  });

  await s3Client.send(command);

  return {
    key,
    url: `https://${BUCKET_NAME}.s3.amazonaws.com/${key}`,
  };
}

// Generate signed URL for private files
async function getSignedDownloadUrl(key, expiresIn = 3600) {
  const command = new GetObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
  });

  return getSignedUrl(s3Client, command, { expiresIn });
}

// Delete file from S3
async function deleteFromS3(key) {
  const command = new DeleteObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
  });

  await s3Client.send(command);
}

// Route handler
app.post('/upload/s3', upload.single('file'), async (req, res) => {
  try {
    const result = await uploadToS3(req.file, 'images');

    // Save to database
    const fileRecord = await File.create({
      originalName: req.file.originalname,
      key: result.key,
      url: result.url,
      size: req.file.size,
      mimeType: req.file.mimetype,
      uploadedBy: req.user.id,
    });

    res.json({
      id: fileRecord._id,
      url: result.url,
    });
  } catch (error) {
    console.error('S3 upload error:', error);
    res.status(500).json({ error: 'Upload failed' });
  }
});

Presigned URLs for Direct Upload

const { PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');

// Generate presigned URL for client-side upload
app.post('/upload/presigned', async (req, res) => {
  const { filename, contentType } = req.body;

  const key = `uploads/${uuidv4()}-${filename}`;

  const command = new PutObjectCommand({
    Bucket: BUCKET_NAME,
    Key: key,
    ContentType: contentType,
  });

  const uploadUrl = await getSignedUrl(s3Client, command, {
    expiresIn: 300, // 5 minutes
  });

  res.json({
    uploadUrl,
    key,
    fileUrl: `https://${BUCKET_NAME}.s3.amazonaws.com/${key}`,
  });
});

// Client-side direct upload
async function uploadDirectToS3(file) {
  // 1. Get presigned URL from server
  const { uploadUrl, key, fileUrl } = await fetch('/api/upload/presigned', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filename: file.name,
      contentType: file.type,
    }),
  }).then(r => r.json());

  // 2. Upload directly to S3
  await fetch(uploadUrl, {
    method: 'PUT',
    body: file,
    headers: {
      'Content-Type': file.type,
    },
  });

  // 3. Return the file URL
  return { key, url: fileUrl };
}

React File Upload Components

Basic File Input

function FileUpload() {
  const [file, setFile] = useState(null);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState(null);

  const handleFileChange = (e) => {
    const selectedFile = e.target.files[0];

    // Client-side validation
    if (selectedFile) {
      if (selectedFile.size > 10 * 1024 * 1024) {
        setError('File size must be less than 10MB');
        return;
      }

      if (!['image/jpeg', 'image/png'].includes(selectedFile.type)) {
        setError('Only JPEG and PNG files allowed');
        return;
      }

      setFile(selectedFile);
      setError(null);
    }
  };

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);
    setProgress(0);

    const formData = new FormData();
    formData.append('file', file);

    try {
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
        // Track upload progress with XMLHttpRequest
      });

      if (!response.ok) {
        throw new Error('Upload failed');
      }

      const result = await response.json();
      console.log('Uploaded:', result);
      setFile(null);
    } catch (err) {
      setError(err.message);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="file-upload">
      <input
        type="file"
        onChange={handleFileChange}
        accept="image/jpeg,image/png"
        disabled={uploading}
      />

      {file && (
        <div className="file-info">
          <p>{file.name} ({(file.size / 1024).toFixed(1)} KB)</p>
          <button onClick={handleUpload} disabled={uploading}>
            {uploading ? 'Uploading...' : 'Upload'}
          </button>
        </div>
      )}

      {uploading && (
        <div className="progress-bar">
          <div style={{ width: `${progress}%` }} />
        </div>
      )}

      {error && <p className="error">{error}</p>}
    </div>
  );
}

Drag and Drop Upload

function DragDropUpload({ onUpload }) {
  const [isDragging, setIsDragging] = useState(false);
  const [files, setFiles] = useState([]);

  const handleDragOver = (e) => {
    e.preventDefault();
    setIsDragging(true);
  };

  const handleDragLeave = (e) => {
    e.preventDefault();
    setIsDragging(false);
  };

  const handleDrop = (e) => {
    e.preventDefault();
    setIsDragging(false);

    const droppedFiles = Array.from(e.dataTransfer.files);
    handleFiles(droppedFiles);
  };

  const handleFiles = (newFiles) => {
    const validFiles = newFiles.filter(file => {
      // Validate file type and size
      const isValidType = ['image/jpeg', 'image/png', 'application/pdf'].includes(file.type);
      const isValidSize = file.size <= 10 * 1024 * 1024;
      return isValidType && isValidSize;
    });

    setFiles(prev => [...prev, ...validFiles.map(file => ({
      file,
      id: Math.random().toString(36).substr(2, 9),
      preview: file.type.startsWith('image/')
        ? URL.createObjectURL(file)
        : null,
      status: 'pending',
      progress: 0,
    }))]);
  };

  const uploadFile = async (fileItem) => {
    const formData = new FormData();
    formData.append('file', fileItem.file);

    setFiles(prev => prev.map(f =>
      f.id === fileItem.id ? { ...f, status: 'uploading' } : f
    ));

    try {
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      });

      const result = await response.json();

      setFiles(prev => prev.map(f =>
        f.id === fileItem.id
          ? { ...f, status: 'complete', url: result.url }
          : f
      ));

      onUpload?.(result);
    } catch (error) {
      setFiles(prev => prev.map(f =>
        f.id === fileItem.id
          ? { ...f, status: 'error', error: error.message }
          : f
      ));
    }
  };

  const removeFile = (id) => {
    setFiles(prev => {
      const file = prev.find(f => f.id === id);
      if (file?.preview) {
        URL.revokeObjectURL(file.preview);
      }
      return prev.filter(f => f.id !== id);
    });
  };

  const uploadAll = () => {
    files
      .filter(f => f.status === 'pending')
      .forEach(uploadFile);
  };

  return (
    <div className="drag-drop-upload">
      <div
        className={`drop-zone ${isDragging ? 'dragging' : ''}`}
        onDragOver={handleDragOver}
        onDragLeave={handleDragLeave}
        onDrop={handleDrop}
      >
        <p>Drag files here or click to browse</p>
        <input
          type="file"
          multiple
          onChange={(e) => handleFiles(Array.from(e.target.files))}
          style={{ display: 'none' }}
          id="file-input"
        />
        <label htmlFor="file-input" className="browse-btn">
          Browse Files
        </label>
      </div>

      {files.length > 0 && (
        <div className="file-list">
          {files.map(fileItem => (
            <div key={fileItem.id} className="file-item">
              {fileItem.preview && (
                <img src={fileItem.preview} alt="" className="preview" />
              )}
              <div className="file-details">
                <span>{fileItem.file.name}</span>
                <span className={`status ${fileItem.status}`}>
                  {fileItem.status}
                </span>
              </div>
              <button onClick={() => removeFile(fileItem.id)}>×</button>
            </div>
          ))}

          <button onClick={uploadAll}>Upload All</button>
        </div>
      )}
    </div>
  );
}

Upload with Progress

function uploadWithProgress(file, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const formData = new FormData();
    formData.append('file', file);

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const progress = Math.round((e.loaded / e.total) * 100);
        onProgress(progress);
      }
    });

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => {
      reject(new Error('Network error'));
    });

    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  });
}

// Usage in component
const [progress, setProgress] = useState(0);

const handleUpload = async (file) => {
  try {
    const result = await uploadWithProgress(file, setProgress);
    console.log('Uploaded:', result);
  } catch (error) {
    console.error('Upload error:', error);
  }
};

Image Processing

# Install sharp for image processing
npm install sharp

const sharp = require('sharp');

// Resize and optimize image
async function processImage(buffer, options = {}) {
  const {
    width = 800,
    height = 600,
    quality = 80,
    format = 'jpeg',
  } = options;

  let pipeline = sharp(buffer);

  // Resize
  pipeline = pipeline.resize(width, height, {
    fit: 'inside',
    withoutEnlargement: true,
  });

  // Convert format and compress
  switch (format) {
    case 'jpeg':
      pipeline = pipeline.jpeg({ quality });
      break;
    case 'png':
      pipeline = pipeline.png({ compressionLevel: 9 });
      break;
    case 'webp':
      pipeline = pipeline.webp({ quality });
      break;
  }

  return pipeline.toBuffer();
}

// Generate multiple sizes
async function generateThumbnails(buffer) {
  const sizes = {
    thumbnail: { width: 150, height: 150 },
    medium: { width: 500, height: 500 },
    large: { width: 1200, height: 1200 },
  };

  const results = {};

  for (const [name, dimensions] of Object.entries(sizes)) {
    results[name] = await sharp(buffer)
      .resize(dimensions.width, dimensions.height, {
        fit: 'cover',
        position: 'centre',
      })
      .jpeg({ quality: 80 })
      .toBuffer();
  }

  return results;
}

// Usage in upload route
app.post('/upload/image', upload.single('image'), async (req, res) => {
  try {
    const thumbnails = await generateThumbnails(req.file.buffer);

    // Upload each size to S3
    const uploads = await Promise.all(
      Object.entries(thumbnails).map(async ([size, buffer]) => {
        return uploadToS3(
          { buffer, originalname: `${size}.jpg`, mimetype: 'image/jpeg' },
          `images/${req.file.originalname}`,
        );
      })
    );

    res.json({ images: uploads });
  } catch (error) {
    res.status(500).json({ error: 'Image processing failed' });
  }
});

Security Best Practices

// 1. Validate file type (both extension and MIME type)
const ALLOWED_TYPES = {
  'image/jpeg': ['.jpg', '.jpeg'],
  'image/png': ['.png'],
  'application/pdf': ['.pdf'],
};

function validateFileType(file) {
  const ext = path.extname(file.originalname).toLowerCase();
  const allowedExts = ALLOWED_TYPES[file.mimetype];

  if (!allowedExts || !allowedExts.includes(ext)) {
    throw new Error('Invalid file type');
  }
}

// 2. Scan for malware (using ClamAV)
const NodeClam = require('clamscan');

const clam = new NodeClam().init({
  clamdscan: { host: 'localhost', port: 3310 },
});

async function scanFile(buffer) {
  const { isInfected, viruses } = await clam.scanStream(buffer);
  if (isInfected) {
    throw new Error(`Malware detected: ${viruses.join(', ')}`);
  }
}

// 3. Generate unique filenames (prevent overwrites)
function generateSecureFilename(originalname) {
  const ext = path.extname(originalname);
  return `${uuidv4()}${ext}`;
}

// 4. Store files outside web root
const UPLOAD_DIR = path.join(__dirname, '..', 'storage', 'uploads');

// 5. Set proper Content-Type and Content-Disposition headers
app.get('/download/:id', async (req, res) => {
  const file = await File.findById(req.params.id);

  res.setHeader('Content-Type', file.mimeType);
  res.setHeader(
    'Content-Disposition',
    `attachment; filename="${file.originalName}"`
  );

  // Stream file
  const stream = fs.createReadStream(file.path);
  stream.pipe(res);
});

// 6. Limit upload rate
const rateLimit = require('express-rate-limit');

const uploadLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10, // 10 uploads per window
  message: 'Too many uploads, please try again later',
});

app.post('/upload', uploadLimiter, upload.single('file'), ...);

Key Takeaways

  • Use Multer for handling multipart/form-data uploads
  • Validate file types on both client and server side
  • Use cloud storage (S3) for production applications
  • Presigned URLs enable secure direct uploads to S3
  • Process images server-side with Sharp for optimization
  • Implement progress tracking for better UX
  • Generate unique filenames to prevent overwrites
  • Rate limit uploads to prevent abuse