Uploading files is a common requirement in modern web applications. From profile pictures and documents to product images and media uploads, developers need a reliable way to handle files securely and efficiently.

In this tutorial, we'll build a full-stack file upload solution using:

  • React for the frontend UI
  • Node.js & Express for the backend API
  • Multer as the middleware for handling multipart/form-data
  • Axios for sending files from React to the backend

By the end of this guide, you will know how to:

  • Create a file upload form in React
  • Send files using FormData
  • Configure Multer to handle single and multiple file uploads
  • Store uploaded files on the server
  • Validate file types and sizes
  • Handle upload errors gracefully

This tutorial focuses on real-world best practices, so the code can be used directly in production-ready projects.

What We'll Build

We'll build a simple but complete file upload feature:

Tech Stack

Who This Tutorial Is For

This tutorial is ideal for:

Basic knowledge of React and Node.js is recommended, but everything related to file uploads will be explained clearly.

Project Overview & Architecture

Before we write any code, let's take a step back and understand how file uploads work in a React + Node.js application, and what role Multer plays in the process.

High-Level Upload Flow

Here's the complete flow of a file upload request:

None
None
None
  1. User selects a file in the React application
  2. React creates a FormData object and appends the file
  3. The file is sent to the backend using an HTTP POST request
  4. Multer intercepts the request and processes multipart/form-data
  5. The file is saved on the server (local storage in this tutorial)
  6. The backend responds with upload status and metadata
  7. React displays success or error feedback

Why Multer?

Express does not handle file uploads out of the box. Files sent via HTML forms use multipart/form-data, which requires special parsing.

Multer is a middleware that:

Without Multer, uploaded files would be inaccessible in Express.

Project Structure

We'll keep the project simple and easy to follow.

Backend (Node.js + Express)

backend/ ├── uploads/ # Uploaded files ├── routes/ │ └── upload.routes.js # Upload API routes ├── middleware/ │ └── upload.js # Multer configuration ├── app.js # Express app ├── package.json

Frontend (React)

frontend/ ├── src/ │ ├── components/ │ │ └── FileUpload.jsx │ ├── services/ │ │ └── uploadService.js │ ├── App.jsx │ └── main.jsx ├── package.json

This separation keeps:

Single vs Multiple File Uploads

In this tutorial, we'll cover:

We'll start with single-file uploads and later extend it to multiple files with minimal changes.

Upload Storage Strategy

For simplicity and clarity, we'll use:

Later in the tutorial, we'll also discuss:

API Endpoint Design

We'll create a clean and predictable API:

Each response will return:

Error Handling Strategy

We'll handle common upload errors:

This ensures a good developer experience and better UX.

Backend Setup — Node.js, Express, and Multer Configuration

In this section, we'll set up the Node.js backend that will receive and process file uploads from our React application.

We'll:

  • Initialize a Node.js project
  • Install required dependencies
  • Create an Express server
  • Configure Multer for file uploads
  • Prepare an uploads/ directory

1. Initialize the Backend Project

Create a new folder for the backend and initialize it:

mkdir backend cd backend npm init -y

2. Install Dependencies

Install Express, Multer, and CORS:

npm install express multer cors

For development convenience, install Nodemon:

npm install -D nodemon

Update package.json:

{ "scripts": { "start": "node app.js", "dev": "nodemon app.js" } }

3. Create the Express App

Create app.js in the backend folder:

const express = require('express'); const cors = require('cors'); const uploadRoutes = require('./routes/upload.routes'); const app = express(); app.use(cors()); app.use(express.json()); // Serve uploaded files statically app.use('/uploads', express.static('uploads')); app.use('/api', uploadRoutes); const PORT = 5000; app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });

4. Create the Uploads Folder

Multer does not automatically create directories. Create it manually:

mkdir uploads

This is where all uploaded files will be stored.

5. Configure Multer Storage

Create a new file: middleware/upload.js

const multer = require('multer'); const path = require('path'); // Storage configuration const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, 'uploads/'); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); cb( null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}` ); }, }); // File filter (optional) const fileFilter = (req, file, cb) => { const allowedTypes = /jpeg|jpg|png|pdf/; const extname = allowedTypes.test( path.extname(file.originalname).toLowerCase() ); const mimetype = allowedTypes.test(file.mimetype); if (extname && mimetype) { cb(null, true); } else { cb(new Error('Only images and PDF files are allowed')); } }; // Multer instance const upload = multer({ storage, limits: { fileSize: 2 * 1024 * 1024 }, // 2MB fileFilter, }); module.exports = upload;

What's Happening Here?

6. Create Upload Routes

Create: routes/upload.routes.js

const express = require('express'); const upload = require('../middleware/upload'); 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 uploaded' }); } res.status(200).json({ message: 'File uploaded successfully', file: { filename: req.file.filename, originalName: req.file.originalname, size: req.file.size, mimetype: req.file.mimetype, path: `/uploads/${req.file.filename}`, }, }); }); module.exports = router;

7. Test the Backend (Optional)

Start the server:

You should see:

Server running on http://localhost:5000

You can test the endpoint using Postman or curl by sending a POST request with multipart/form-data and a file field.

Backend Is Ready ✅

At this point:

Frontend Setup — React File Upload Form

Now that the backend is ready, let's build the React frontend that allows users to select and upload files to our Node.js + Multer API.

In this section, we'll:

1. Create the React Project

Using Vite (recommended for modern React apps):

npm create vite@latest frontend -- --template react cd frontend npm install npm run dev

Your app will run at:

http://localhost:5173

2. Install Axios

Axios makes it easy to send multipart/form-data requests.

npm install axios

3. Create the File Upload Component

Create a new file:

src/components/FileUpload.jsx

import { useState } from 'react'; import axios from 'axios'; const FileUpload = () => { const [file, setFile] = useState(null); const [message, setMessage] = useState(''); const [uploading, setUploading] = useState(false); const handleFileChange = (e) => { setFile(e.target.files[0]); }; const handleUpload = async () => { if (!file) { setMessage('Please select a file'); return; } const formData = new FormData(); formData.append('file', file); try { setUploading(true); setMessage(''); const response = await axios.post( 'http://localhost:5000/api/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', }, } ); setMessage(response.data.message); } catch (error) { if (error.response?.data?.message) { setMessage(error.response.data.message); } else { setMessage('File upload failed'); } } finally { setUploading(false); } }; return ( <div style={{ maxWidth: 400, margin: '2rem auto' }}> <h2>File Upload</h2> <input type="file" onChange={handleFileChange} /> <button onClick={handleUpload} disabled={uploading}> {uploading ? 'Uploading...' : 'Upload'} </button> {message && <p>{message}</p>} </div> ); }; export default FileUpload;

4. Use the Component in App.jsx

Update src/App.jsx:

import FileUpload from './components/FileUpload'; function App() { return ( <div> <FileUpload /> </div> ); } export default App;

How File Upload Works in React

Key Points

  • FormData is required for multipart/form-data
  • Axios automatically handles binary file transfer
  • The field name must match Multer's config:
  • upload.single('file')

6. Test the Full Flow

Common Issues & Fixes

Frontend Upload Complete ✅

You now have:

Multiple File Uploads with Multer and React

Single file uploads are useful, but many real-world applications require uploading multiple files at once -for example, photo galleries, document attachments, or bulk uploads.

In this section, we'll extend our existing setup to support multiple file uploads with minimal changes on both the backend and frontend.

1. Backend: Multiple File Upload Endpoint

Multer supports multiple files using upload.array().

Update Upload Routes

Open routes/upload.routes.js and add a new endpoint:

// Multiple file upload router.post('/uploads', upload.array('files', 5), (req, res) => { if (!req.files || req.files.length === 0) { return res.status(400).json({ message: 'No files uploaded' }); } const uploadedFiles = req.files.map((file) => ({ filename: file.filename, originalName: file.originalname, size: file.size, mimetype: file.mimetype, path: `/uploads/${file.filename}`, })); res.status(200).json({ message: 'Files uploaded successfully', files: uploadedFiles, }); });

Key Points

2. Frontend: Update the Upload Component

Now let's modify the React component to support multiple file selection.

Update FileUpload.jsx

import { useState } from 'react'; import axios from 'axios'; const FileUpload = () => { const [files, setFiles] = useState([]); const [message, setMessage] = useState(''); const [uploading, setUploading] = useState(false); const handleFileChange = (e) => { setFiles([...e.target.files]); }; const handleUpload = async () => { if (files.length === 0) { setMessage('Please select at least one file'); return; } const formData = new FormData(); files.forEach((file) => { formData.append('files', file); }); try { setUploading(true); setMessage(''); const response = await axios.post( 'http://localhost:5000/api/uploads', formData, { headers: { 'Content-Type': 'multipart/form-data', }, } ); setMessage(response.data.message); } catch (error) { if (error.response?.data?.message) { setMessage(error.response.data.message); } else { setMessage('Multiple file upload failed'); } } finally { setUploading(false); } }; return ( <div style={{ maxWidth: 400, margin: '2rem auto' }}> <h2>Multiple File Upload</h2> <input type="file" multiple onChange={handleFileChange} /> <button onClick={handleUpload} disabled={uploading}> {uploading ? 'Uploading...' : 'Upload Files'} </button> {message && <p>{message}</p>} </div> ); }; export default FileUpload;

3. Important Notes on Field Names

The field name in React must match the Multer configuration:

A mismatch will result in No files uploaded errors.

4. Testing Multiple File Uploads

5. Handling Partial Failures (Optional Tip)

By default, Multer:

For advanced use cases, you can:

Multiple File Uploads Complete ✅

You now support:

File Validation, Error Handling, and Security Best Practices

Handling file uploads safely is critical. Without proper validation and security controls, file uploads can become a serious attack vector.

In this section, we'll strengthen our implementation by:

1. File Size and Type Validation (Backend)

We already added basic validation in Multer, but let's review and improve it.

Improved middleware/upload.js

const multer = require("multer"); const path = require("node:path"); const MAX_SIZE = 2 * 1024 * 1024; // 2MB const ALLOWED_TYPES = /jpeg|jpg|png|pdf/; const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, "uploads/"); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); cb( null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}` ); } }); const fileFilter = (req, file, cb) => { const extname = ALLOWED_TYPES.test( path.extname(file.originalname).toLowerCase() ); const mimetype = ALLOWED_TYPES.test(file.mimetype); if (extname && mimetype) { cb(null, true); } else { cb(new Error("Invalid file type. Only JPG, PNG, and PDF are allowed.")); } }; const upload = multer({ storage, limits: { fileSize: MAX_SIZE }, fileFilter }); module.exports = upload;

2. Centralized Error Handling for Multer

Multer errors are not automatically JSON-friendly. Let's fix that.

Add an Error Handler Middleware

Update app.js:

app.use((err, req, res, next) => { if (err instanceof require('multer').MulterError) { return res.status(400).json({ message: err.message, }); } if (err) { return res.status(400).json({ message: err.message || 'File upload error', }); } next(); });

Common Multer Errors

3. Frontend: Better Error Feedback

Improve error handling in React to show meaningful messages.

Update Axios Error Handling

catch (error) { if (error.response?.data?.message) { setMessage(error.response.data.message); } else { setMessage('Unexpected upload error'); } }

This ensures users understand why an upload failed.

4. Security Best Practices for File Uploads

1. Never Trust Client-Side Validation

Client-side checks can be bypassed. Always validate:

On the server side.

2. Rename Uploaded Files

We already do this:

file.fieldname + '-' + uniqueSuffix

This prevents:

3. Do NOT Execute Uploaded Files

Never serve uploaded files from executable directories.

We safely serve uploads as static assets:

app.use('/uploads', express.static('uploads'));

For sensitive files:

4. Restrict MIME Types Explicitly

Never allow:

Unless absolutely required.

5. Limit Upload Size

Always define limits:

limits: { fileSize: MAX_SIZE }

Prevents denial-of-service attacks.

5. Optional: Client-Side Validation (UX Only)

You may also add client-side validation for better UX:

<input type="file" accept=".jpg,.jpeg,.png,.pdf" multiple />

⚠️ Reminder: This improves UX, not security.

Upload Security Checklist ✅

✔ File size limits ✔ Allowed MIME types ✔ Unique filenames ✔ Centralized error handling ✔ Clear frontend feedback

Displaying Uploaded Files and Previewing Images in React

Uploading files is only half the story. In many applications, users also need to see what they've uploaded -especially for images and documents.

In this section, we'll:

1. Serving Uploaded Files from the Backend

We already exposed the uploads directory in app.js:

app.use('/uploads', express.static('uploads'));

This means any uploaded file is accessible via:

http://localhost:5000/uploads/<filename>

This is safe for public files. For private files, you'd add authentication (covered later).

2. Returning File URLs from the API

Let's slightly improve the API response so React can easily consume it.

Example Backend Response

{ "message": "Files uploaded successfully", "files": [ { "filename": "files-1735631823-123456.png", "url": "http://localhost:5000/uploads/files-1735631823-123456.png", "mimetype": "image/png" } ] }

Update Upload Routes (Optional Improvement)

const BASE_URL = 'http://localhost:5000'; const uploadedFiles = req.files.map((file) => ({ filename: file.filename, url: `${BASE_URL}/uploads/${file.filename}`, mimetype: file.mimetype, }));

3. Updating React State to Store Uploaded Files

Now let's enhance the frontend to store and render uploaded files.

Update FileUpload.jsx

import { useState } from 'react'; import axios from 'axios'; const FileUpload = () => { const [files, setFiles] = useState([]); const [uploadedFiles, setUploadedFiles] = useState([]); const [message, setMessage] = useState(''); const [uploading, setUploading] = useState(false); const handleFileChange = (e) => { setFiles([...e.target.files]); }; const handleUpload = async () => { if (files.length === 0) { setMessage('Please select at least one file'); return; } const formData = new FormData(); files.forEach((file) => formData.append('files', file)); try { setUploading(true); setMessage(''); const response = await axios.post( 'http://localhost:5000/api/uploads', formData ); setUploadedFiles(response.data.files); setMessage(response.data.message); } catch (error) { setMessage( error.response?.data?.message || 'File upload failed' ); } finally { setUploading(false); } }; return ( <div style={{ maxWidth: 600, margin: '2rem auto' }}> <h2>Upload & Preview Files</h2> <input type="file" multiple onChange={handleFileChange} /> <button onClick={handleUpload} disabled={uploading}> {uploading ? 'Uploading...' : 'Upload'} </button> {message && <p>{message}</p>} <div style={{ marginTop: '1rem' }}> {uploadedFiles.map((file, index) => ( <div key={index} style={{ marginBottom: '1rem' }}> {file.mimetype.startsWith('image/') ? ( <img src={file.url} alt={file.filename} style={{ maxWidth: '100%', height: 'auto' }} /> ) : ( <a href={file.url} target="_blank" rel="noopener noreferrer"> View File </a> )} </div> ))} </div> </div> ); }; export default FileUpload;

4. Image Preview vs File Link Logic

file.mimetype.startsWith('image/')

This allows us to:

You can extend this logic for:

5. UX Improvements (Optional)

Enhancements you can add:

Uploaded File Preview Complete ✅

You now have:

Upload Progress, Environment Configuration, and Production Tips

To make your file upload feature production-ready, we'll add upload progress feedback, clean up environment configuration, and discuss important deployment considerations.

1. Showing Upload Progress in React

Axios provides an onUploadProgress callback that lets us track upload progress in real time.

Update FileUpload.jsx

import { useState } from 'react'; import axios from 'axios'; const FileUpload = () => { const [files, setFiles] = useState([]); const [uploadedFiles, setUploadedFiles] = useState([]); const [message, setMessage] = useState(''); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); const handleFileChange = (e) => { setFiles([...e.target.files]); setProgress(0); }; const handleUpload = async () => { if (files.length === 0) { setMessage('Please select at least one file'); return; } const formData = new FormData(); files.forEach((file) => formData.append('files', file)); try { setUploading(true); setMessage(''); const response = await axios.post( import.meta.env.VITE_API_URL + '/uploads', formData, { onUploadProgress: (progressEvent) => { const percent = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); setProgress(percent); }, } ); setUploadedFiles(response.data.files); setMessage(response.data.message); } catch (error) { setMessage(error.response?.data?.message || 'Upload failed'); } finally { setUploading(false); } }; return ( <div style={{ maxWidth: 600, margin: '2rem auto' }}> <h2>Upload Files</h2> <input type="file" multiple onChange={handleFileChange} /> <button onClick={handleUpload} disabled={uploading}> {uploading ? 'Uploading...' : 'Upload'} </button> {uploading && ( <div style={{ marginTop: '1rem' }}> <progress value={progress} max="100" /> <span> {progress}%</span> </div> )} {message && <p>{message}</p> } {/* Preview Section */} <div style={{ marginTop: '1rem' }}> {uploadedFiles.map((file, index) => ( <div key={index}> {file.mimetype.startsWith('image/') ? ( <img src={file.url} alt="" style={{ maxWidth: 200 }} /> ) : ( <a href={file.url} target="_blank">View File</a> )} </div> ))} </div> </div> ); }; export default FileUpload;

2. Using Environment Variables

Hardcoding URLs is not recommended.

Frontend: .env

Create frontend/.env:

VITE_API_URL=http://localhost:5000/api

Restart the dev server after adding this.

Backend: .env

Install dotenv:

npm install dotenv

Create backend/.env:

PORT=5000 BASE_URL=http://localhost:5000

Update app.js:

require('dotenv').config(); const PORT = process.env.PORT || 5000;

3. Production Deployment Tips

1. Use Cloud Storage

For production apps, consider:

Local disk storage is not ideal for:

2. Protect Upload Endpoints

Add:

npm install express-rate-limit

3. Virus & Malware Scanning

For sensitive uploads:

4. Cleanup Strategy

Avoid disk bloat:

5. HTTPS in Production

Always upload files over HTTPS to protect:

4. Common Production Mistakes to Avoid

❌ Allowing all file types ❌ No size limits ❌ Public access to private files ❌ Hardcoded URLs ❌ No upload feedback

Production-Ready Uploads ✅

At this stage, you have:

Best Practices Summary and Real-World Use Cases

To wrap up the core implementation, let's consolidate everything you've learned into clear best practices, then look at real-world scenarios where this upload pattern is commonly used.

This section helps readers apply the tutorial confidently in production projects.

1. File Upload Best Practices (Quick Checklist)

Backend (Node.js + Multer)

✔ Always validate file types and sizes ✔ Use unique filenames (never trust original names) ✔ Set strict upload limits ✔ Handle Multer errors centrally ✔ Separate upload logic into middleware ✔ Serve public files safely ✔ Protect private uploads with authentication

Frontend (React)

✔ Use FormData for file uploads ✔ Match field names with Multer configuration ✔ Show upload progress feedback ✔ Display clear success/error messages ✔ Preview images when possible ✔ Avoid hardcoding API URLs

Security

✔ Never trust client-side validation ✔ Restrict MIME types explicitly ✔ Avoid executing uploaded files ✔ Use HTTPS in production ✔ Consider virus scanning for sensitive uploads

2. Common Real-World Use Cases

1. Profile Picture Upload

Pattern: Example:

upload.single('avatar')

2. Document Upload (PDF, DOC)

Pattern: Example:

upload.array('documents', 10)

3. Product Image Gallery

Pattern:

4. Attachments in Forms (Support Tickets, Messages)

Pattern:

5. Admin Upload Dashboards

Pattern:

3. Scaling the Upload System

When your app grows, consider:

  • Moving files to cloud storage
  • Storing file metadata in a database
  • Using background jobs for processing
  • Adding image optimization (resize, compress)
  • Leveraging CDNs for delivery

4. When NOT to Use Multer

Multer is excellent for: ✔ Small to medium files ✔ Standard web apps

But consider alternatives for:

Section Summary ✅

By now, you've learned how to:

  • Build a React file upload UI
  • Send files using Axios and FormData
  • Handle uploads in Node.js with Multer
  • Validate, secure, and preview files
  • Prepare your app for production

Conclusion and Next Steps

File uploads are a foundational feature in modern web applications, and implementing them correctly requires attention to usability, security, and scalability.

In this tutorial, you built a complete, production-ready file upload system using React, Node.js, Express, and Multer, covering both frontend and backend concerns.

What You've Learned

By following this guide, you now know how to:

  • Create file upload forms in React
  • Send files using FormData and Axios
  • Handle single and multiple file uploads with Multer
  • Store files securely on the server
  • Validate file size and type
  • Handle upload errors gracefully
  • Display uploaded files and preview images
  • Show upload progress in real time
  • Configure environments for development and production
  • Apply best practices for security and scalability

This setup is flexible and can be adapted to almost any real-world application.

Recommended Enhancements

To take this project further, consider implementing:

  • Drag-and-drop uploads (e.g., react-dropzone)
  • Image processing (resize, crop, compress)
  • Cloud storage integration (AWS S3, Cloudinary)
  • Authenticated uploads (JWT, session-based)
  • Private file access using signed URLs
  • Chunked uploads for large files
  • Database integration for file metadata

Final Thoughts

File uploads may look simple on the surface, but doing them right makes a huge difference in:

With the patterns shown in this tutorial, you have a solid foundation you can reuse across multiple projects.

You can get the full source code on our GitHub.

That's just the basics. If you need more deep learning about React, you can take the following cheap course:

Thanks!

Originally published at https://www.djamware.com.