Vercel Image Build Bottleneck: CDN Migration and OG Generation
Images in /public cost build time even with 'on-demand' optimization. Move to external CDN and generate OG images at runtime for 2-3 minute savings.
Your 500 images live in /public. Each deployment processes them all—
compression, format conversion, responsive variants. Move them to an external CDN
and eliminate 2-3 minutes of build time that shouldn't exist in the first place.
The Hidden Build Cost
Vercel's documentation says images are "optimized on demand." True—but with a catch. When images live in your repo, the build process still handles:
- Hashing for cache invalidation
- Copying to output directory
- Generating image manifests for Next.js Image component
- Processing import statements in JavaScript bundles
// Each import adds build overhead
import heroImage from '../public/images/hero.jpg';
import avatar from '../public/images/avatar.png';
// × 500 images = measurable build timeFor a site with 500 images averaging 500KB each, you're adding 30-60 seconds just to handle static assets that don't change between deploys.
External CDN: Zero Build Cost
Move images to Cloudflare R2, Bunny CDN, or AWS S3 + CloudFront. The Next.js Image component works identically—just point to external URLs:
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.yoursite.com',
pathname: '/images/**',
},
],
},
};
// Component usage (unchanged)
<Image
src="https://cdn.yoursite.com/images/hero.jpg"
width={1200}
height={630}
alt="Hero"
/>Build time: images don't exist in your repo, so nothing to process. Runtime: Vercel's Image Optimization API still resizes and converts on-demand.
OpenGraph Images: The Worst Offender
Dynamic OG images (social preview cards) are often generated at build time. For 500 pages, that's 500 image generations—each taking 200-500ms:
// ❌ Build-time generation (slow)
// pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
await generateOGImage(params.slug); // 300ms per page
return { props: { ... } };
}500 pages × 300ms = 150 seconds just for OG images. This is absurd when most of these images will never be requested.
On-Demand OG Generation
Generate OG images when social crawlers actually request them:
// app/api/og/[slug]/route.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || 'Default Title';
return new ImageResponse(
(
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<h1 style={{ color: 'white', fontSize: 60 }}>{title}</h1>
</div>
),
{ width: 1200, height: 630 }
);
}Build time: zero OG generation. Runtime: Edge function generates on first request (~50ms), then cached at the edge for subsequent requests.
Caching Generated Images
Add proper cache headers so generated images persist:
export async function GET(request: Request) {
// ... generate image ...
return new ImageResponse(jsx, {
width: 1200,
height: 630,
headers: {
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}First request: generate and cache. All subsequent requests: served from edge cache with no function invocation.
Static Export Fallback
If you need static OG images for some pages (SEO-critical content), generate only those at build time:
// scripts/generate-critical-og.ts
const criticalPages = ['/', '/pricing', '/features'];
for (const page of criticalPages) {
await generateAndSaveOG(page);
}
// package.json
{
"scripts": {
"prebuild": "tsx scripts/generate-critical-og.ts",
"build": "next build"
}
}3 critical pages generate at build. 500 blog posts generate on-demand. Best of both worlds.
Migration Checklist
- Audit current images:
find public -name "*.jpg" -o -name "*.png" | wc -l - Choose CDN: Cloudflare R2 (free egress), Bunny ($0.01/GB), or existing AWS
- Upload images: Use
rclone syncor CDN's CLI - Update imports: Replace local paths with CDN URLs
- Configure remotePatterns: Allow your CDN domain in next.config
- Move OG generation: Convert to Edge API route
- Delete from repo: Remove
/public/imagesfrom git
Results
A 500-page blog with 500 images saw:
- Build time: 5.5min → 1.5min (image processing eliminated)
- Git repo size: 800MB → 50MB (no binary assets)
- Clone time: 45s → 5s (faster CI starts)
- OG images: Generate on-demand instead of 500 at build
The build pipeline should compile code, not process media. Separate concerns, separate systems, faster deploys.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement