Pushduck
Pushduck// S3 uploads for any framework

Image Uploads

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

Image Upload Guide

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()
    .maxFileSize('5MB')
    .maxFiles(1)
    .formats(['jpeg', 'png', 'webp']),

  // Multiple images with optimization
  galleryImages: s3.image()
    .maxFileSize('10MB')
    .maxFiles(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.presignedUrl || 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>
  );
}

Client-Side Metadata for Images

Pass contextual information from your UI to organize and categorize images:

New Feature: You can now pass metadata directly from the client when uploading images. This allows you to send album IDs, tags, categories, or any contextual data from your UI to the server.

// Client component
import { upload } from '@/lib/upload-client';
import { useState } from 'react';

export function GalleryUpload() {
  const [selectedAlbum, setSelectedAlbum] = useState('vacation-2025');
  const [tags, setTags] = useState<string[]>([]);
  const [isFeatured, setIsFeatured] = useState(false);
  
  const { uploadFiles, files } = upload.galleryImages({
    onSuccess: (results) => {
      console.log(`Uploaded to album: ${selectedAlbum}`);
    }
  });
  
  const handleUpload = (selectedFiles: File[]) => {
    // Pass UI state as metadata
    uploadFiles(selectedFiles, {
      albumId: selectedAlbum,
      tags: tags,
      featured: isFeatured,
      uploadSource: 'gallery-manager',
      category: 'user-content'
    });
  };
  
  return (
    <div>
      <select value={selectedAlbum} onChange={(e) => setSelectedAlbum(e.target.value)}>
        <option value="vacation-2025">Vacation 2025</option>
        <option value="family">Family Photos</option>
      </select>
      
      <input type="checkbox" checked={isFeatured} onChange={(e) => setIsFeatured(e.target.checked)} />
      <label>Mark as Featured</label>
      
      <input type="file" multiple onChange={(e) => e.target.files && handleUpload(Array.from(e.target.files))} />
    </div>
  );
}

Server-side validation:

// Server router
.middleware(async ({ req, metadata }) => {
  const user = await authenticateUser(req);
  
  // Validate client-provided album access
  if (metadata?.albumId) {
    const hasAccess = await db.albums.canUserAccess(metadata.albumId, user.id);
    if (!hasAccess) throw new Error('Access denied to album');
  }
  
  return {
    // Client metadata (validated)
    albumId: metadata?.albumId,
    tags: metadata?.tags || [],
    featured: metadata?.featured || false,
    
    // Server metadata (trusted)
    userId: user.id,
    uploadedAt: new Date().toISOString()
  };
})
.paths({
  generateKey: (ctx) => {
    const { metadata, file } = ctx;
    // Use metadata in path generation
    return `albums/${metadata.albumId}/${metadata.featured ? 'featured/' : ''}${file.name}`;
  }
})

Security: Always validate client metadata in middleware. Never trust client-provided user IDs or permissions.

Image Validation & Processing

Format Validation

Prop

Type

const s3Router = s3.createRouter({
  productImages: s3.image()
    .maxFileSize('8MB')
    .maxFiles(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 Processing Integration

⚠️ Important: Pushduck handles file uploads only. Image processing (resizing, optimization, format conversion) requires external tools. The examples below show integration patterns using .onUploadComplete() hooks.

Popular image processing tools:

  • Sharp - Fast Node.js processing (recommended)
  • Cloudinary - Full-service image CDN with transformations
  • Imgix - Real-time URL-based transformations

See Philosophy for why we don't include processing.

Integration Example: Sharp for Resizing

⚠️ Bandwidth Tradeoff: Server-side processing requires downloading the file from S3 to your server (inbound bandwidth), processing it, then uploading variants back (outbound bandwidth).

This negates Pushduck's "server never touches files" benefit!

Better Alternatives:

  • Client-side preprocessing - Resize before upload (see below)
  • URL-based processing - Cloudinary/Imgix transform via URL (no download)
  • Async queue - Process in background worker, not blocking upload
// First: npm install sharp
import sharp from 'sharp';

const s3Router = s3.createRouter({
  optimizedImages: s3.image()
    .maxFileSize('15MB')
    .maxFiles(10)
    .formats(['jpeg', 'png', 'webp'])
    .dimensions({ maxWidth: 1920, maxHeight: 1080 })
    .onUploadComplete(async ({ file, url, metadata }) => {
      // ⚠️ This downloads the file from S3 (inbound bandwidth)
      const imageBuffer = await fetch(url).then(r => r.arrayBuffer());
      
      // Process with Sharp (external tool)
      const variants = await Promise.all([
        sharp(imageBuffer).resize(150, 150, { fit: 'cover' }).toBuffer(),
        sharp(imageBuffer).resize(800, 600, { fit: 'inside' }).toBuffer(),
        sharp(imageBuffer).resize(1920, 1080, { fit: 'inside' }).toBuffer(),
      ]);
      
      // ⚠️ Upload variants back to S3 (outbound bandwidth)
      // await uploadVariantsToS3(variants);
  }),
});

Better Alternative: Client-Side Preprocessing

✅ Recommended Approach: Process images before upload on the client side. This maintains Pushduck's "server never touches files" architecture and saves bandwidth.

Client-Side Resize Before Upload

// Use browser-image-compression for client-side resizing
// npm install browser-image-compression

import imageCompression from 'browser-image-compression';
import { upload } from '@/lib/upload-client';

export function ClientSideImageUpload() {
  const { uploadFiles, isUploading } = upload.profilePicture();

  const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // ✅ Resize on client BEFORE upload (no server bandwidth)
    const options = {
      maxSizeMB: 1,
      maxWidthOrHeight: 1920,
      useWebWorker: true,
    };

    try {
      const compressedFile = await imageCompression(file, options);
      
      // Upload the already-processed file
      await uploadFiles([compressedFile]);
      
      console.log('Original:', file.size / 1024, 'KB');
      console.log('Compressed:', compressedFile.size / 1024, 'KB');
    } catch (error) {
      console.error('Compression error:', error);
    }
  };

  return (
    <input
      type="file"
      accept="image/*"
      onChange={handleImageSelect}
      disabled={isUploading}
    />
  );
}

Benefits:

  • No server bandwidth - file never touches your server
  • Faster uploads - smaller files upload quicker
  • Lower S3 costs - store smaller files
  • Edge compatible - no Node.js processing
  • Better UX - instant preview of processed image

Alternative: URL-Based Processing (Cloudinary/Imgix)

✅ Best of Both Worlds: Upload original to S3, transform via URL without downloading. Zero server bandwidth!

const s3Router = s3.createRouter({
  images: s3.image()
    .maxFileSize('10MB')
    .onUploadComplete(async ({ file, url, metadata }) => {
      // Save original URL - NO download needed
      await db.images.create({
        userId: metadata.userId,
        originalUrl: url,
        s3Key: file.key,
      });
      
      // ✅ Cloudinary can fetch from your S3 URL and transform
      // No bandwidth on your server!
    }),
});

// Client: Use Cloudinary URLs for transformations
function ImageDisplay({ s3Url }: { s3Url: string }) {
  // Cloudinary fetches from S3 and transforms (their bandwidth, not yours)
  const cloudinaryUrl = `https://res.cloudinary.com/your-cloud/image/fetch/w_400,h_400,c_fill/${encodeURIComponent(s3Url)}`;
  
  return <img src={cloudinaryUrl} alt="Transformed" />;
}

Advanced Patterns (Optional)

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()
    .maxFileSize('20MB')
    .maxFiles(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()
    .maxFileSize('10MB')
    .maxFiles(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.presignedUrl || 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()
    .maxFileSize('15MB')
    .maxFiles(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.