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:
- User selects a file in the React application
- React creates a
FormDataobject and appends the file - The file is sent to the backend using an HTTP
POSTrequest - Multer intercepts the request and processes
multipart/form-data - The file is saved on the server (local storage in this tutorial)
- The backend responds with upload status and metadata
- 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.jsonFrontend (React)
frontend/ ├── src/ │ ├── components/ │ │ └── FileUpload.jsx │ ├── services/ │ │ └── uploadService.js │ ├── App.jsx │ └── main.jsx ├── package.jsonThis 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 -y2. Install Dependencies
Install Express, Multer, and CORS:
npm install express multer corsFor development convenience, install Nodemon:
npm install -D nodemonUpdate 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 uploadsThis 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:5000You 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 devYour app will run at:
http://localhost:51732. Install Axios
Axios makes it easy to send multipart/form-data requests.
npm install axios3. 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
FormDatais required formultipart/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 + '-' + uniqueSuffixThis 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/apiRestart the dev server after adding this.
Backend: .env
Install dotenv:
npm install dotenvCreate backend/.env:
PORT=5000 BASE_URL=http://localhost:5000Update 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-limit3. 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
FormDataand 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.