Every blog post on Boottify has a cover image. At full resolution, these images weigh 300-550KB each. The blog listing page loads 12 posts at once — that's potentially 6.6MB of images on a single page. On the admin blog table, those same images render as tiny 64x48px thumbnails. Serving a 550KB image for a 64px thumbnail is absurd.
We built a thumbnail pipeline that generates 4 responsive WebP variants per image, reducing bandwidth by 93-99% depending on the context.
THE PROBLEM: FULL-SIZE IMAGES EVERYWHERE
Before this change, every image reference pointed to the full-size original:
// Admin table: 64x48px thumbnail using a 1920x1080 image
<img src="/uploads/blog/building-deployment-pipeline.jpg" />
// Blog card: 400px wide container using a 1920px image
<Image src={coverImage} width={400} height={225} />
Next.js <Image> helps with lazy loading and format conversion, but it still fetches the full image and resizes on the server per request. We needed pre-generated variants.
THE 4-TIER VARIANT SYSTEM
We defined four thumbnail sizes based on actual usage patterns across the platform:
| Variant | Width | Quality | Use Case |
|---|---|---|---|
-lg | 1200px | 80% | Desktop blog cards, hero sections |
-md | 768px | 78% | Tablet, related posts, sidebar |
-sm | 480px | 76% | Mobile blog cards, compact layouts |
-xs | 120px | 72% | Admin tables, tiny previews |
All variants are generated as WebP — typically 60-80% smaller than JPEG at equivalent quality. A 550KB JPEG original becomes:
-lg.webp— ~85KB (desktop)-md.webp— ~35KB (tablet)-sm.webp— ~15KB (mobile)-xs.webp— ~5KB (admin thumbnail)
CONVENTION-BASED URL DERIVATION
Instead of storing thumbnail URLs in the database, we derive them from the original URL using a simple naming convention:
// Original: /uploads/blog/building-pipeline.jpg
// Variants: /uploads/blog/building-pipeline-lg.webp
// /uploads/blog/building-pipeline-md.webp
// /uploads/blog/building-pipeline-sm.webp
// /uploads/blog/building-pipeline-xs.webp
export function getThumbnailUrl(
originalUrl: string | null | undefined,
variant: ThumbnailVariant
): string {
if (!originalUrl) return "";
const [urlPath, query] = originalUrl.split("?");
const lastDot = urlPath.lastIndexOf(".");
if (lastDot === -1) return originalUrl;
const base = urlPath.substring(0, lastDot);
const thumbUrl = `${base}${THUMBNAIL_VARIANTS[variant].suffix}.webp`;
return query ? `${thumbUrl}?${query}` : thumbUrl;
}
This function is pure — no Node.js dependencies, safe for both server and client components. Zero database changes required.
RESPONSIVE LOADING WITH <PICTURE>
On the blog listing page, we use the HTML <picture> element to serve the right variant based on viewport width:
<picture>
<source
media="(max-width: 639px)"
srcSet={getThumbnailUrl(coverImage, "sm")}
type="image/webp"
/>
<source
media="(max-width: 1023px)"
srcSet={getThumbnailUrl(coverImage, "md")}
type="image/webp"
/>
<source
srcSet={getThumbnailUrl(coverImage, "lg")}
type="image/webp"
/>
<img src={coverImage} alt={title} loading="lazy" />
</picture>
The browser picks the smallest variant that fits the viewport. Desktop users get -lg, tablets get -md, phones get -sm. The original JPEG is only loaded as a fallback for browsers that don't support WebP (effectively none in 2026).
AUTOMATIC GENERATION ON UPLOAD
When an admin uploads a new blog image via the media API, thumbnails are generated asynchronously using Sharp:
// POST /api/admin/blog/media
const optimized = await optimizeImage(buffer, file.type, "blog");
const url = await uploadFile(key, optimized.buffer, contentType);
// Non-blocking thumbnail generation
generateThumbnails(optimized.buffer, key).catch((err) =>
logger.warn("Thumbnail generation failed", { key, error: String(err) })
);
The upload returns immediately with the original URL. Thumbnails are generated in the background — if they fail, the original image still works as a fallback.
MIGRATION SCRIPT FOR EXISTING IMAGES
For the 39 existing blog images, we wrote a batch migration script that reads each .jpg file and generates all 4 WebP variants. The entire migration produced 156 thumbnails totaling 6.1MB — compared to 19.2MB for the 39 originals.
THE RESULTS
- Blog listing page: 6.6MB → 420KB (93% reduction)
- Admin blog table: 550KB per row → 5KB per row (99% reduction)
- Mobile blog: 6.6MB → 180KB (97% reduction)
- 156 WebP thumbnails generated, 6.1MB total storage overhead
- Zero database changes — convention-based URL derivation
The entire system — client URL helper, server Sharp generator, upload integration, migration script, and responsive <picture> components — took a single session to implement. If your platform serves images at fixed sizes, you're wasting bandwidth.


