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

Niraj Dhungana
Niraj DhunganaJuly 16, 2025
Share:
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:

command
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:

page.tsx
// 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:

api/image.ts
// 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:

page.tsx
// 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:

next.config.js
export const config = {
  api: {
    bodyParser: false,
  },
};

2. Using Formidable for File Handling

Formidable is perfect for handling multipart form data in Node.js:

typescript
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:

typescript
filename: (name, ext, path, form) => {
  return Date.now().toString() + '_' + path.originalFilename;
}

Advanced Features

File Validation

Add file type and size validation:

typescript
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:

typescript
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:

typescript
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

  1. File Type Validation: Always validate file types on both client and server
  2. File Size Limits: Set reasonable file size limits to prevent abuse
  3. Sanitize Filenames: Remove or replace dangerous characters in filenames
  4. Rate Limiting: Implement rate limiting to prevent spam uploads

Production Deployment

When deploying to production:

  1. Serverless Limitations: Most serverless platforms have file size limits
  2. Persistent Storage: Consider using cloud storage (AWS S3, Cloudinary) for production
  3. 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:

  1. Cloud Storage: AWS S3, Google Cloud Storage, or Azure Blob Storage
  2. Image Services: Cloudinary, ImageKit, or Uploadcare
  3. 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!