
Setting Up a Monorepo That Doesn't Make You Want to Quit
I've seen the pattern in almost every codebase I've inherited: users upload images, the app stores them raw, and the frontend serves them as-is. Someone uploads a 8MB DSLR photo as their profile picture. It gets resized to 40x40 pixels in CSS. The browser still downloads all 8MB.
It takes maybe a day to build a proper pipeline. Here's exactly how I do it.
The Goals
- Accept uploads in any common format
- Validate on the client before wasting upload bandwidth
- Process server-side: resize, convert to WebP/AVIF, strip metadata
- Store originals and processed variants separately
- Serve via CDN with proper caching headers
- Generate
srcset-compatible URLs for responsive images
Client-Side Validation First
Don't wait for a failed upload to tell the user their 4K raw file is too big:
typescript// hooks/useImageUpload.ts interface UploadOptions { maxSizeMb?: number acceptedFormats?: string[] maxDimension?: number } const DEFAULT_OPTIONS: Required<UploadOptions> = { maxSizeMb: 10, acceptedFormats: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], maxDimension: 8000, } export async function validateImageFile( file: File, options: UploadOptions = {} ): Promise<{ valid: true } | { valid: false; reason: string }> { const opts = { ...DEFAULT_OPTIONS, ...options } if (!opts.acceptedFormats.includes(file.type)) { return { valid: false, reason: `Format not supported. Please upload a JPG, PNG, or WebP image.`, } } const sizeMb = file.size / (1024 * 1024) if (sizeMb > opts.maxSizeMb) { return { valid: false, reason: `File too large (${sizeMb.toFixed(1)}MB). Maximum size is ${opts.maxSizeMb}MB.`, } } // Check actual image dimensions const dimensions = await getImageDimensions(file) if (dimensions.width > opts.maxDimension || dimensions.height > opts.maxDimension) { return { valid: false, reason: `Image too large (${dimensions.width}×${dimensions.height}px). Maximum is ${opts.maxDimension}px.`, } } return { valid: true } } function getImageDimensions(file: File): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file) const img = new Image() img.onload = () => { resolve({ width: img.naturalWidth, height: img.naturalHeight }) URL.revokeObjectURL(url) } img.onerror = () => { reject(new Error('Could not read image dimensions')) URL.revokeObjectURL(url) } img.src = url }) }
And a preview before upload:
typescriptexport function useImagePreview(file: File | null) { const [preview, setPreview] = useState<string | null>(null) useEffect(() => { if (!file) { setPreview(null) return } const url = URL.createObjectURL(file) setPreview(url) return () => URL.revokeObjectURL(url) }, [file]) return preview }
Server-Side Processing with Sharp
sharp is the standard Node.js image processing library. It's fast, handles all formats, and runs native binaries:
bashnpm install sharp npm install -D @types/sharp
typescript// lib/image-processor.ts import sharp from 'sharp' export interface ImageVariant { name: string width: number height?: number // If not provided, aspect ratio is preserved format: 'webp' | 'avif' | 'jpeg' quality: number } export const AVATAR_VARIANTS: ImageVariant[] = [ { name: 'sm', width: 40, format: 'webp', quality: 80 }, { name: 'md', width: 80, format: 'webp', quality: 80 }, { name: 'lg', width: 160, format: 'webp', quality: 85 }, { name: 'sm', width: 40, format: 'jpeg', quality: 85 }, // JPEG fallback ] export const POST_IMAGE_VARIANTS: ImageVariant[] = [ { name: 'thumbnail', width: 400, format: 'webp', quality: 80 }, { name: 'card', width: 800, format: 'webp', quality: 82 }, { name: 'hero', width: 1200, format: 'webp', quality: 85 }, { name: 'hero-avif', width: 1200, format: 'avif', quality: 75 }, // JPEG fallbacks { name: 'thumbnail', width: 400, format: 'jpeg', quality: 85 }, { name: 'card', width: 800, format: 'jpeg', quality: 85 }, { name: 'hero', width: 1200, format: 'jpeg', quality: 88 }, ] export interface ProcessedVariant { variant: ImageVariant buffer: Buffer size: number } export async function processImage( input: Buffer, variants: ImageVariant[] ): Promise<ProcessedVariant[]> { // Strip EXIF metadata (GPS data, camera info, etc.) const base = sharp(input).rotate() // rotate() applies EXIF orientation then strips it const results = await Promise.all( variants.map(async (variant) => { let pipeline = base.clone().resize({ width: variant.width, height: variant.height, fit: 'cover', position: 'entropy', // crops to the most interesting part withoutEnlargement: true, // don't upscale small images }) if (variant.format === 'webp') { pipeline = pipeline.webp({ quality: variant.quality }) } else if (variant.format === 'avif') { pipeline = pipeline.avif({ quality: variant.quality }) } else { pipeline = pipeline.jpeg({ quality: variant.quality, mozjpeg: true }) } const buffer = await pipeline.toBuffer() return { variant, buffer, size: buffer.length, } }) ) return results }
Storage with S3-Compatible Object Storage
typescript// lib/storage.ts import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3' import { randomUUID } from 'crypto' const s3 = new S3Client({ region: process.env.AWS_REGION!, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, }, }) const BUCKET = process.env.S3_BUCKET! const CDN_URL = process.env.CDN_URL! // e.g., https://cdn.yourapp.com export interface StoredFile { key: string url: string size: number } export async function uploadFile( buffer: Buffer, options: { folder: string filename?: string contentType: string cacheControl?: string } ): Promise<StoredFile> { const filename = options.filename ?? randomUUID() const key = `${options.folder}/${filename}` await s3.send( new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: buffer, ContentType: options.contentType, CacheControl: options.cacheControl ?? 'public, max-age=31536000, immutable', // Immutable because filenames include content hash }) ) return { key, url: `${CDN_URL}/${key}`, size: buffer.length, } }
The Upload API Route
typescript// routes/uploads.ts import multer from 'multer' import { processImage, AVATAR_VARIANTS } from '../lib/image-processor' import { uploadFile } from '../lib/storage' import { createHash } from 'crypto' // Store uploads in memory — we process and discard immediately const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, // 10MB }) app.post( '/api/upload/avatar', requireAuth, upload.single('image'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No file provided' }) } // Content-hash the filename so cache busting works automatically const hash = createHash('md5').update(req.file.buffer).digest('hex').slice(0, 8) const variants = await processImage(req.file.buffer, AVATAR_VARIANTS) // Upload all variants in parallel const uploaded = await Promise.all( variants.map(({ variant, buffer }) => uploadFile(buffer, { folder: `avatars/${req.user.id}`, filename: `${variant.name}-${hash}.${variant.format}`, contentType: `image/${variant.format}`, }) ) ) // Build the response: variant name → URL mapping const urls = Object.fromEntries( variants.map((v, i) => [ `${v.variant.name}_${v.variant.format}`, uploaded[i].url, ]) ) // Save to DB await db.user.update({ where: { id: req.user.id }, data: { avatarUrls: urls }, }) res.json({ urls }) } )
Serving Responsive Images
With variants stored, your <img> tags can use srcset for responsive delivery:
typescript// components/Avatar.tsx interface AvatarProps { urls: Record<string, string> name: string size?: 'sm' | 'md' | 'lg' } export function Avatar({ urls, name, size = 'md' }: AvatarProps) { const pxMap = { sm: 40, md: 80, lg: 160 } const px = pxMap[size] // Prefer AVIF > WebP > JPEG return ( <picture> {urls.sm_webp && ( <source type="image/webp" srcSet={`${urls.sm_webp} 40w, ${urls.md_webp} 80w, ${urls.lg_webp} 160w`} sizes={`${px}px`} /> )} <img src={urls[`${size}_jpeg`] ?? urls.md_jpeg} width={px} height={px} alt={`${name}'s avatar`} loading="lazy" /> </picture> ) }
For post hero images with AVIF:
tsx<picture> <source type="image/avif" srcSet={`${urls.thumbnail_avif} 400w, ${urls.card_avif} 800w, ${urls.hero_avif} 1200w`} sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px" /> <source type="image/webp" srcSet={`${urls.thumbnail_webp} 400w, ${urls.card_webp} 800w, ${urls.hero_webp} 1200w`} sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px" /> <img src={urls.hero_jpeg} alt={post.imageAlt} width={1200} height={630} loading="eager" // Hero image load eagerly fetchPriority="high" /> </picture>
AVIF is significantly smaller than WebP for the same quality, but browser support is slightly narrower (still 90%+ globally). The <picture> element handles the fallback gracefully.
What This Gets You
A raw 3MB JPEG profile photo becomes three WebP variants totaling roughly 60KB and three JPEG fallbacks totaling about 120KB. The browser downloads the appropriate size and format on a modern browser viewing a large avatar, that's a 30KB WebP file instead of 3MB of original data.
For post images, AVIF hero images are typically 40–60% smaller than the equivalent JPEG. At scale, that's meaningful bandwidth and meaningful load time.
The pipeline is a day of work. The payoff is permanent.