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
Drag & Drop Image Gallery
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 Excellence: With proper optimization, validation, and processing, your image uploads will provide an excellent user experience while maintaining performance and quality.