Guides/Uploads

Image Uploads

Complete guide to handling image uploads with optimization, validation, and processing

Image Uploads

Handle image uploads with built-in optimization, validation, and processing features for the best user experience.

Images are the most common upload type. This guide covers everything from basic setup to advanced optimization techniques for production apps.

Basic Image Upload Setup

Server Configuration

// app/api/upload/route.ts
import { s3 } from "@/lib/upload";

const s3Router = s3.createRouter({
  // Basic image upload
  profilePicture: s3.image()
    .max('5MB')
    .count(1)
    .formats(['jpeg', 'png', 'webp']),

  // Multiple images with optimization
  galleryImages: s3.image()
    .max('10MB')
    .count(10)
    .formats(['jpeg', 'png', 'webp', 'gif']),
});

export type AppS3Router = typeof s3Router;
export const { GET, POST } = s3Router.handlers;

Client Implementation

// components/image-uploader.tsx
import { upload } from "@/lib/upload-client";

export function ImageUploader() {
  const { uploadFiles, files, isUploading } = upload.galleryImages;

  const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFiles = Array.from(e.target.files || []);
    uploadFiles(selectedFiles);
  };

  return (
    <div className="image-uploader">
      <input
        type="file"
        multiple
        accept="image/*"
        onChange={handleImageSelect}
        className="file-input"
      />

      <div className="image-preview-grid">
        {files.map((file) => (
          <div key={file.id} className="image-preview">
            {file.status === "success" && (
              <img src={file.url} alt={file.name} className="preview-image" />
            )}
            {file.status === "uploading" && (
              <div className="upload-progress">
                <progress value={file.progress} max={100} />
                <span>{file.progress}%</span>
              </div>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

Image Validation & Processing

Format Validation

PropTypeDefault
aspectRatio?
number
-
maxHeight?
number
-
minHeight?
number
-
maxWidth?
number
-
minWidth?
number
-
formats?
string[]
["jpeg", "png", "webp", "gif"]
const s3Router = s3.createRouter({
  productImages: s3.image()
    .max('8MB')
    .count(5)
    .formats(['jpeg', 'png', 'webp'])
    .dimensions({
      minWidth: 800,
      maxWidth: 4000,
      minHeight: 600,
      maxHeight: 3000,
    })
    .aspectRatio(16 / 9, { tolerance: 0.1 })
    .middleware(async ({ req, file, metadata }) => {
    // Custom validation
      const imageMetadata = await getImageMetadata(file);

      if (
        imageMetadata.hasTransparency &&
        !["png", "webp"].includes(imageMetadata.format)
      ) {
        throw new Error("Transparent images must be PNG or WebP format");
      }

      if (imageMetadata.colorProfile !== "sRGB") {
        console.warn(
          `Image ${file.name} uses ${imageMetadata.colorProfile} color profile`
        );
    }

      return { 
        ...metadata,
        userId: await getUserId(req),
        ...imageMetadata
      };
  }),
});

Image Optimization

const s3Router = s3.createRouter({
  optimizedImages: s3.image()
    .max('15MB')
    .count(10)
    .formats(['jpeg', 'png', 'webp'])
    .dimensions({ maxWidth: 1920, maxHeight: 1080 })
    .onUploadComplete(async ({ file, url, metadata }) => {
    // Generate multiple sizes
      await generateImageVariants(file, [
        { name: "thumbnail", width: 150, height: 150, fit: "cover" },
        { name: "medium", width: 800, height: 600, fit: "inside" },
        { name: "large", width: 1920, height: 1080, fit: "inside" },
      ]);
  }),
});

Advanced Image Features

Responsive Image Generation

interface ImageVariant {
  name: string;
  width: number;
  height?: number;
  quality?: number;
  format?: "jpeg" | "png" | "webp";
}

const imageVariants: ImageVariant[] = [
  { name: "thumbnail", width: 150, height: 150, quality: 80 },
  { name: "small", width: 400, quality: 85 },
  { name: "medium", width: 800, quality: 85 },
  { name: "large", width: 1200, quality: 85 },
  { name: "xlarge", width: 1920, quality: 90 },
];

const s3Router = s3.createRouter({
  responsiveImages: s3.image()
    .max('20MB')
    .count(5)
    .formats(['jpeg', 'png', 'webp'])
    .onUploadComplete(async ({ file, url, metadata }) => {
      // Generate responsive variants
      const variants = await Promise.all(
        imageVariants.map((variant) => generateImageVariant(file, variant))
      );

      // Save variant information to database
      await saveImageVariants(file.key, variants, metadata.userId);
  }),
});

// Client-side responsive image component
export function ResponsiveImage({
  src,
  alt,
  sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw",
}: {
  src: string;
  alt: string;
  sizes?: string;
}) {
  const variants = useImageVariants(src);

  if (!variants) return <img src={src} alt={alt} />;

  const srcSet = [
    `${variants.small} 400w`,
    `${variants.medium} 800w`,
    `${variants.large} 1200w`,
    `${variants.xlarge} 1920w`,
  ].join(", ");

  return (
    <img
      src={variants.medium}
      srcSet={srcSet}
      sizes={sizes}
      alt={alt}
      loading="lazy"
    />
  );
}

Image Upload with Crop & Preview

import { useState } from 'react'
import { ImageCropper } from './image-cropper'
import { upload } from '@/lib/upload-client'

export function ImageUploadWithCrop() {
  const [selectedFile, setSelectedFile] = useState<File | null>(null)
  const [croppedImage, setCroppedImage] = useState<Blob | null>(null)
  const { uploadFiles, isUploading } = upload.profilePicture

  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (file) setSelectedFile(file)
  }

  const handleCropComplete = (croppedBlob: Blob) => {
    setCroppedImage(croppedBlob)
  }

  const handleUpload = async () => {
    if (!croppedImage) return
    
    const file = new File([croppedImage], 'cropped-image.jpg', {
      type: 'image/jpeg'
    })
    
    await uploadFiles([file])
    
    // Reset state
    setSelectedFile(null)
    setCroppedImage(null)
  }

  return (
    <div className="image-upload-crop">
      {!selectedFile && (
        <input
          type="file"
          accept="image/*"
          onChange={handleFileSelect}
        />
      )}
      
      {selectedFile && !croppedImage && (
        <ImageCropper
          image={selectedFile}
          aspectRatio={1} // Square crop
          onCropComplete={handleCropComplete}
        />
      )}
      
      {croppedImage && (
        <div className="crop-preview">
          <img 
            src={URL.createObjectURL(croppedImage)} 
            alt="Cropped preview" 
          />
          <div className="crop-actions">
            <button onClick={() => setCroppedImage(null)}>
              Recrop
            </button>
            <button 
              onClick={handleUpload}
              disabled={isUploading}
            >
              {isUploading ? 'Uploading...' : 'Upload'}
            </button>
          </div>
        </div>
      )}
    </div>
  )
}
import { useRef, useCallback } from 'react'
import ReactCrop, { Crop, PixelCrop } from 'react-image-crop'
import 'react-image-crop/dist/ReactCrop.css'

interface ImageCropperProps {
  image: File
  aspectRatio?: number
  onCropComplete: (croppedBlob: Blob) => void
}

export function ImageCropper({ 
  image, 
  aspectRatio = 1, 
  onCropComplete 
}: ImageCropperProps) {
  const imgRef = useRef<HTMLImageElement>(null)
  const [crop, setCrop] = useState<Crop>({
    unit: '%',
    x: 25,
    y: 25,
    width: 50,
    height: 50
  })
  
  const imageUrl = URL.createObjectURL(image)

  const getCroppedImage = useCallback(async (
    image: HTMLImageElement,
    crop: PixelCrop
  ): Promise<Blob> => {
    const canvas = document.createElement('canvas')
    const ctx = canvas.getContext('2d')!
    
    const scaleX = image.naturalWidth / image.width
    const scaleY = image.naturalHeight / image.height
    
    canvas.width = crop.width * scaleX
    canvas.height = crop.height * scaleY
    
    ctx.imageSmoothingQuality = 'high'
    
    ctx.drawImage(
      image,
      crop.x * scaleX,
      crop.y * scaleY,
      crop.width * scaleX,
      crop.height * scaleY,
      0,
      0,
      canvas.width,
      canvas.height
    )
    
    return new Promise(resolve => {
      canvas.toBlob(blob => resolve(blob!), 'image/jpeg', 0.9)
    })
  }, [])

  const handleCropComplete = useCallback(async (crop: PixelCrop) => {
    if (imgRef.current && crop.width && crop.height) {
      const croppedBlob = await getCroppedImage(imgRef.current, crop)
      onCropComplete(croppedBlob)
    }
  }, [getCroppedImage, onCropComplete])

  return (
    <div className="image-cropper">
      <ReactCrop
        crop={crop}
        onChange={setCrop}
        onComplete={handleCropComplete}
        aspect={aspectRatio}
      >
        <img
          ref={imgRef}
          src={imageUrl}
          alt="Crop preview"
          style={{ maxWidth: '100%', maxHeight: '400px' }}
        />
      </ReactCrop>
    </div>
  )
}
// Server-side image processing after upload
const s3Router = s3.createRouter({
  profilePicture: s3.image()
    .max('10MB')
    .count(1)
    .formats(['jpeg', 'png', 'webp'])
    .onUploadComplete(async ({ file, url, metadata }) => {
      // Generate avatar sizes
      await Promise.all([
        generateImageVariant(file, {
          name: 'avatar-small',
          width: 32,
          height: 32,
          fit: 'cover',
          quality: 90
        }),
        generateImageVariant(file, {
          name: 'avatar-medium',
          width: 64,
          height: 64,
          fit: 'cover',
          quality: 90
        }),
        generateImageVariant(file, {
          name: 'avatar-large',
          width: 128,
          height: 128,
          fit: 'cover',
          quality: 95
        })
      ])
      
      // Update user profile with new avatar
      await updateUserAvatar(metadata.userId, {
        original: url,
        small: getVariantUrl(file.key, 'avatar-small'),
        medium: getVariantUrl(file.key, 'avatar-medium'),
        large: getVariantUrl(file.key, 'avatar-large')
      })
  })
})

Image Upload Patterns

import { useDropzone } from "react-dropzone";
import { upload } from "@/lib/upload-client";

export function ImageGalleryUploader() {
  const { uploadFiles, files, isUploading } = upload.galleryImages;

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    accept: {
      "image/*": [".jpeg", ".jpg", ".png", ".webp", ".gif"],
    },
    maxFiles: 10,
    onDrop: (acceptedFiles) => {
      uploadFiles(acceptedFiles);
    },
  });

  const removeFile = (fileId: string) => {
    // Implementation to remove file from gallery
  };

  return (
    <div className="image-gallery-uploader">
      <div
        {...getRootProps()}
        className={`dropzone ${isDragActive ? "active" : ""}`}
      >
        <input {...getInputProps()} />
        {isDragActive ? (
          <p>Drop the images here...</p>
        ) : (
          <div className="dropzone-content">
            <svg className="upload-icon" viewBox="0 0 24 24">
              <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />
            </svg>
            <p>Drag & drop images here, or click to select</p>
            <p className="dropzone-hint">Up to 10 images, max 10MB each</p>
          </div>
        )}
      </div>

      {files.length > 0 && (
        <div className="image-grid">
          {files.map((file) => (
            <div key={file.id} className="image-item">
              {file.status === "success" && (
                <div className="image-wrapper">
                  <img src={file.url} alt={file.name} />
                  <button
                    className="remove-button"
                    onClick={() => removeFile(file.id)}
                  >
                    ×
                  </button>
                </div>
              )}

              {file.status === "uploading" && (
                <div className="upload-placeholder">
                  <div className="progress-circle">
                    <svg viewBox="0 0 36 36">
                      <path
                        d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
                        fill="none"
                        stroke="#e5e7eb"
                        strokeWidth="3"
                      />
                      <path
                        d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
                        fill="none"
                        stroke="#3b82f6"
                        strokeWidth="3"
                        strokeDasharray={`${file.progress}, 100`}
                      />
                    </svg>
                    <span>{file.progress}%</span>
                  </div>
                  <p>{file.name}</p>
                </div>
              )}

              {file.status === "error" && (
                <div className="error-placeholder">
                  <span className="error-icon">⚠️</span>
                  <p>Upload failed</p>
                  <button onClick={() => uploadFiles([file.originalFile])}>
                    Retry
                  </button>
                </div>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Image Upload with Metadata

const s3Router = s3.createRouter({
  portfolioImages: s3.image()
    .max('15MB')
    .count(20)
    .formats(['jpeg', 'png', 'webp'])
    .middleware(async ({ req, file, metadata }) => {
      const { userId } = await authenticateUser(req);

      // Extract and validate metadata
      const imageMetadata = await extractImageMetadata(file);

      // Return enriched metadata
      return {
          ...metadata,
        userId,
          uploadedBy: userId,
          uploadedAt: new Date(),
          originalFilename: file.name,
          fileHash: await calculateFileHash(file),
        ...imageMetadata,
        };
    })
    .onUploadComplete(async ({ file, url, metadata }) => {
      // Save detailed image information
        await saveImageToDatabase({
        userId: metadata.userId,
          s3Key: file.key,
        url: url,
        filename: metadata.originalFilename,
          size: file.size,
          dimensions: {
          width: metadata.width,
          height: metadata.height,
          },
        format: metadata.format,
        colorProfile: metadata.colorProfile,
        hasTransparency: metadata.hasTransparency,
        exifData: metadata.exif,
        hash: metadata.fileHash,
        });
    }),
});

Performance Best Practices

Client-Side Optimization

Optimize images before upload

import { compress } from 'image-conversion'

export function optimizeImage(file: File): Promise<File> {
  return compress(file, {
    quality: 0.8,
    type: 'image/webp',
    width: 1920,
    height: 1080,
    orientation: true // Auto-rotate based on EXIF
  })
}

// Usage in upload component
const handleFileSelect = async (files: File[]) => {
  const optimizedFiles = await Promise.all(
    files.map(file => optimizeImage(file))
  )
  uploadFiles(optimizedFiles)
}

Progressive Loading

Implement blur-to-sharp loading

export function ProgressiveImage({ 
  src, 
  blurDataURL, 
  alt 
}: {
  src: string
  blurDataURL: string
  alt: string
}) {
  const [isLoaded, setIsLoaded] = useState(false)
  
  return (
    <div className="progressive-image">
      <img
        src={blurDataURL}
        alt={alt}
        className={`blur-image ${isLoaded ? 'hidden' : ''}`}
      />
      <img
        src={src}
        alt={alt}
        className={`sharp-image ${isLoaded ? 'visible' : ''}`}
        onLoad={() => setIsLoaded(true)}
      />
    </div>
  )
}

Lazy Loading

Load images as they enter viewport

import { useIntersectionObserver } from '@/hooks/use-intersection-observer'

export function LazyImage({ src, alt, ...props }) {
  const [ref, isIntersecting] = useIntersectionObserver({
    threshold: 0.1,
    rootMargin: '50px'
  })
  
  return (
    <div ref={ref} className="lazy-image-container">
      {isIntersecting ? (
        <img src={src} alt={alt} {...props} />
      ) : (
        <div className="lazy-placeholder">Loading...</div>
      )}
    </div>
  )
}

Project Structure

image-uploader.tsx
image-cropper.tsx
image-gallery.tsx
progressive-image.tsx

Image Excellence: With proper optimization, validation, and processing, your image uploads will provide an excellent user experience while maintaining performance and quality.