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.
By the end of this tutorial, you'll have:
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
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>
);
}
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' });
}
}
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,
},
};
}
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,
},
};
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;
},
});
We use timestamps to prevent filename conflicts:
filename: (name, ext, path, form) => {
return Date.now().toString() + '_' + path.originalFilename;
}
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);
}
};
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);
}
};
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' });
}
}
When deploying to production:
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
While this tutorial focuses on local storage, consider these alternatives:
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:
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!