How to Upload Images to Public Directory in Next.js with TypeScript


Ever wondered how to handle image uploads directly to your Next.js public directory? While most tutorials focus on cloud storage solutions, sometimes you need a simple local file upload system. Today, I'll show you exactly how to build a complete image upload feature using Next.js, TypeScript, and Tailwind CSS.
⚠️ Note: This tutorial demonstrates how to upload images to a local folder within a Next.js project during local development. Please be aware that this approach won’t work on most live/production deployments (like Vercel) since those environments don’t allow writing to the file system at runtime. This is shared purely for educational purposes based on a local project setup.
What We'll Build
By the end of this tutorial, you'll have:
- A drag-and-drop image upload interface
- File validation and preview functionality
- Server-side API endpoint for handling uploads
- Automatic directory creation
- Display of uploaded images
- TypeScript support throughout
Project Setup
We'll start with a Next.js project using TypeScript and Tailwind CSS. If you don't have one set up, create it with:
npx create-next-app@latest image-upload-demo
cd image-upload-demo
npm install formidable axios
npm install --save-dev @types/formidable
Building the Upload Interface
Let's create our main upload component with proper state management:
// pages/index.tsx
import { useState } from 'react';
import axios from 'axios';
interface HomeProps {
directories: string[];
}
export default function Home({ directories }: HomeProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
setSelectedImage(URL.createObjectURL(file));
setSelectedFile(file);
}
};
const handleUpload = async () => {
if (!selectedFile) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('image', selectedFile);
const response = await axios.post('/api/image', formData);
console.log('Upload successful:', response.data);
// Reset form after successful upload
setSelectedImage(null);
setSelectedFile(null);
} catch (error) {
console.error('Upload failed:', error);
} finally {
setUploading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
<h1 className="text-2xl font-bold text-center mb-6">Image Upload</h1>
{/* Upload Area */}
<label className="block cursor-pointer">
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
{selectedImage ? (
<img
src={selectedImage}
alt="Preview"
className="max-w-full h-48 object-cover mx-auto rounded"
/>
) : (
<div className="text-gray-500">
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<p>Click to select image</p>
</div>
)}
</div>
</label>
{/* Upload Button */}
<button
onClick={handleUpload}
disabled={!selectedFile || uploading}
className={`w-full mt-4 py-2 px-4 rounded font-medium transition-all ${
uploading || !selectedFile
? 'bg-gray-400 cursor-not-allowed opacity-50'
: 'bg-blue-600 hover:bg-blue-700 text-white'
}`}
>
{uploading ? 'Uploading...' : 'Upload Image'}
</button>
{/* Display Uploaded Images */}
{directories.length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4">Uploaded Images</h2>
<div className="grid grid-cols-2 gap-4">
{directories.map((imageName) => (
<a
key={imageName}
href={`/images/${imageName}`}
target="_blank"
rel="noopener noreferrer"
className="block hover:opacity-75 transition-opacity"
>
<img
src={`/images/${imageName}`}
alt={imageName}
className="w-full h-24 object-cover rounded border"
/>
</a>
))}
</div>
</div>
)}
</div>
</div>
);
}
Creating the API Endpoint
Now let's build the server-side API that handles the actual file upload:
// pages/api/image.ts
import { NextApiRequest, NextApiResponse } from 'next';
import formidable from 'formidable';
import path from 'path';
import fs from 'fs/promises';
// Disable Next.js default body parser
export const config = {
api: {
bodyParser: false,
},
};
// Promise-based wrapper for formidable
function readFile(
req: NextApiRequest,
saveLocally?: boolean
): Promise<{ fields: formidable.Fields; files: formidable.Files }> {
const options: formidable.Options = {};
if (saveLocally) {
options.uploadDir = path.join(process.cwd(), 'public/images');
options.filename = (name, ext, path, form) => {
return Date.now().toString() + '_' + path.originalFilename;
};
}
const form = formidable(options);
return new Promise((resolve, reject) => {
form.parse(req, (err, fields, files) => {
if (err) reject(err);
resolve({ fields, files });
});
});
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// Only allow POST requests
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
// Ensure the images directory exists
const imageDir = path.join(process.cwd(), 'public/images');
try {
await fs.readdir(imageDir);
} catch (error) {
// Directory doesn't exist, create it
await fs.mkdir(imageDir, { recursive: true });
}
// Process the uploaded file
await readFile(req, true);
res.status(200).json({ message: 'Upload successful' });
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ message: 'Upload failed' });
}
}
Server-Side Rendering for Image Display
To display the uploaded images, we'll use getServerSideProps
to read the images directory:
// Add this to your pages/index.tsx file
import fs from 'fs/promises';
import path from 'path';
export async function getServerSideProps() {
let directories: string[] = [];
try {
const imageDir = path.join(process.cwd(), 'public/images');
directories = await fs.readdir(imageDir);
} catch (error) {
// Directory doesn't exist or is empty
directories = [];
}
return {
props: {
directories,
},
};
}
Understanding the Key Concepts
1. Disabling Next.js Body Parser
Next.js has a built-in body parser that doesn't work well with file uploads. We disable it with:
export const config = {
api: {
bodyParser: false,
},
};
2. Using Formidable for File Handling
Formidable is perfect for handling multipart form data in Node.js:
const form = formidable({
uploadDir: path.join(process.cwd(), 'public/images'),
filename: (name, ext, path, form) => {
return Date.now().toString() + '_' + path.originalFilename;
},
});
3. Preventing Name Collisions
We use timestamps to prevent filename conflicts:
filename: (name, ext, path, form) => {
return Date.now().toString() + '_' + path.originalFilename;
}
Advanced Features
File Validation
Add file type and size validation:
const validateFile = (file: File): boolean => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
const maxSize = 5 * 1024 * 1024; // 5MB
if (!allowedTypes.includes(file.type)) {
alert('Please select a valid image file');
return false;
}
if (file.size > maxSize) {
alert('File size must be less than 5MB');
return false;
}
return true;
};
// Use in handleFileChange
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files[0]) {
const file = event.target.files[0];
if (!validateFile(file)) return;
setSelectedImage(URL.createObjectURL(file));
setSelectedFile(file);
}
};
Progress Tracking
Add upload progress with axios:
const handleUpload = async () => {
if (!selectedFile) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('image', selectedFile);
const response = await axios.post('/api/image', formData, {
onUploadProgress: (progressEvent) => {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
setUploadProgress(progress);
}
});
console.log('Upload successful:', response.data);
} catch (error) {
console.error('Upload failed:', error);
} finally {
setUploading(false);
setUploadProgress(0);
}
};
Error Handling
Implement comprehensive error handling:
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
try {
// Ensure directory exists
const imageDir = path.join(process.cwd(), 'public/images');
try {
await fs.access(imageDir);
} catch {
await fs.mkdir(imageDir, { recursive: true });
}
const { files } = await readFile(req, true);
if (!files.image) {
return res.status(400).json({ message: 'No image file provided' });
}
res.status(200).json({
message: 'Upload successful',
filename: files.image.originalFilename
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ message: 'Internal server error' });
}
}
Security Considerations
- File Type Validation: Always validate file types on both client and server
- File Size Limits: Set reasonable file size limits to prevent abuse
- Sanitize Filenames: Remove or replace dangerous characters in filenames
- Rate Limiting: Implement rate limiting to prevent spam uploads
Production Deployment
When deploying to production:
- Serverless Limitations: Most serverless platforms have file size limits
- Persistent Storage: Consider using cloud storage (AWS S3, Cloudinary) for production
- CDN Integration: Use a CDN for better image delivery performance
Troubleshooting Common Issues
Problem: "Directory not found" errors Solution: Ensure the images directory exists with proper permissions
Problem: Files not appearing after upload Solution: Check that the public directory is correctly configured
Problem: Large file uploads failing Solution: Increase the file size limits in your formidable configuration
Alternative Approaches
While this tutorial focuses on local storage, consider these alternatives:
- Cloud Storage: AWS S3, Google Cloud Storage, or Azure Blob Storage
- Image Services: Cloudinary, ImageKit, or Uploadcare
- Database Storage: Store images as base64 in your database (not recommended for large files)
Conclusion
You now have a complete image upload system that saves files directly to your Next.js public directory! This approach is perfect for development, small applications, or when you need full control over your files.
The key takeaways:
- Disable Next.js body parser for file uploads
- Use formidable for robust file handling
- Implement proper error handling and validation
- Consider security implications in production
Remember to test thoroughly across different browsers and file types. While this local approach works great for development and small projects, consider cloud storage solutions for production applications with high traffic.
Feel free to extend this implementation with features like image resizing, thumbnail generation, or integration with your favorite image processing library!
Happy coding!