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
Prop | Type | Default |
---|---|---|
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
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.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.