Image Optimization & CDN Strategy: The Pipeline I Kept Boring
Part 6 explains the image optimization pipeline used in this Next.js 16 and Strapi blog, focusing on trusted origins, Strapi media formats, image cache headers, development proxying, and SSRF-safe boundaries.
Image optimization sounds like the kind of work where you should have a dramatic architecture diagram.
Object storage. A media CDN. Signed URLs. Format negotiation. Device-aware variants. Edge transforms. Maybe a queue somewhere, because every pipeline eventually grows a queue if nobody stops it.
I almost went there.
Then I looked at the actual blog.
This is a writing-heavy site. Most pages are text. The expensive mistake would not be failing to invent a world-class media platform. The expensive mistake would be letting a few images punch holes through an otherwise static, cacheable, secure rendering path.
So the goal became smaller and stricter:
Make images predictable.
Not magical. Not endlessly configurable. Predictable.
Real Situation
The blog gets media from Strapi and renders through Next.js 16.
That sounds ordinary until you put all the constraints on the table:
public pages are static-first
Strapi owns uploaded media
article markdown can contain images
social metadata needs reliable image URLs
local development uses localhost:1337
production must not turn an image proxy into an SSRF endpoint
Next.js 16 blocks private image sources for good security reasons
This is the kind of work that looks small until it breaks.
The core Next.js image configuration is intentionally explicit:
There are two choices in there I care about more than the usual "modern formats" line.
First, the allowed origins are narrow. Images are not allowed from arbitrary hosts just because a CMS field said so.
Second, SVG is not casually enabled. It is easy to talk about image performance and forget that images are also an input boundary. I do not want uploaded media to become a script delivery mechanism.
No new CDN abstraction. No custom optimizer service. Let the framework produce optimized variants and make sure the output is cacheable.
That is less exciting than a diagram. It is also easier to operate.
What Went Wrong
The first wrong instinct was to treat image optimization as a checkbox.
"Use next/image."
That advice is not wrong. It is just incomplete.
next/image still needs trusted origins, stable dimensions, realistic sizes, cache headers, failure behavior, and a story for development. If any one of those is sloppy, the optimizer becomes another place for production-only surprises.
The development issue was the first annoying one.
In local dev, Strapi serves uploads from localhost:1337. With Next.js 16, private image sources are blocked more aggressively. That is a good default. It also means a naive local Strapi image URL can fail while the production URL works.
The repo handles that by normalizing Strapi image URLs and proxying localhost uploads only in development:
ts
exportfunctiongetStrapiImageUrl(imageUrl: string | null | undefined): string {
if (!imageUrl) return'/placeholder.jpg';
const fullUrl = imageUrl.startsWith('http') ? imageUrl : `${STRAPI_URL}${imageUrl}`;
// Proxy localhost images in development to bypass Next.js 16 private IP blockingconst isDev = process.env.NODE_ENV === 'development';
if (isDev && fullUrl.includes('localhost:1337/uploads/')) {
const uploadsPath = fullUrl.split('/uploads/')[1];
if (uploadsPath) {
return`/api/strapi-image/${uploadsPath}`;
}
}
return fullUrl;
}
That looks like a small helper. It is actually an architectural boundary.
Development gets parity with the optimized image path, but production does not need a localhost escape hatch.
Tension
The tension was security versus convenience.
When an image breaks locally, the easy fix is to loosen everything. Allow any host. Proxy any URL. Let the CMS decide.
That is how an image feature turns into a security bug.
The dev-only Strapi proxy makes the boundary explicit:
That params: Promise shape is not decorative either. It follows the Next.js 16 async route API. The small things matter here because image paths tend to sit outside the happy path until the day every article page has a broken hero.
Mistake
The mistake I nearly made was over-preloading.
Preloading feels responsible. It also steals bandwidth from things the browser may have prioritized better on its own.
The current prefetch manager leaves blog image preloading alone:
ts
const preloadBlogAssets = useCallback(
(blogData: {
cover?: { url?: string } | null;
author?: {
documentId: string;
name: string;
email: string;
bio: string;
slug?: string;
} | null;
}) => {
if (!blogData) return;
// Skip image preloading - images are served from Strapi GraphQL with different paths// The cover.url from GraphQL doesn't match the actual served path structure// Note: Author avatar not available in current data structure
},
[]
);
That is an unglamorous decision, but it is the right one.
Do not preload what you cannot identify reliably. A wrong preload is not free. It competes with CSS, fonts, scripts, and the actual visible content.
Insight
The useful model was not "optimize images."
The useful model was:
"Build one boring image pipeline and refuse exceptions."
For Strapi media, that starts before Next.js even sees the image. Strapi can expose multiple generated formats, so the app should prefer an appropriate CMS variant instead of always starting from the original upload:
ts
exportfunctiongetOptimizedStrapiImageUrl(cover:
| {
url: string;
formats?: {
large?: { url: string; width?: number; height?: number };
medium?: { url: string; width?: number; height?: number };
small?: { url: string; width?: number; height?: number };
thumbnail?: { url: string; width?: number; height?: number };
};
}
| null
| undefined,
preferredSize: 'large' | 'medium' | 'small' | 'thumbnail' = 'medium'): string {
if (!cover) return'/placeholder.jpg';
// Try to get the preferred size from formatsconst preferredFormat = cover.formats?.[preferredSize];
if (preferredFormat?.url) {
const imageUrl = preferredFormat.url.startsWith('http')
? preferredFormat.url
: `${STRAPI_URL}${preferredFormat.url}`;
returnproxyLocalhostImage(imageUrl);
}
That is the first half of the pipeline: choose the best source you already have.
The second half is rendering it with honest layout information. Markdown images go through the shared optimized image component with explicit dimensions and responsive sizing:
Most article-body images are not the first thing the reader needs. The text is. I would rather let the document become readable quickly than pretend every diagram deserves urgent network priority.
Surprise
The surprise was how much of the CDN strategy was really just discipline around boundaries.
The general image proxy route has an origin allowlist because arbitrary image URLs are dangerous:
That code exists because image proxies are one of those features that look harmless from the UI and ugly from the threat model.
If a user-controlled URL can make your server fetch arbitrary hosts, you do not have an image feature. You have an SSRF primitive.
I do not want that trade.
Learning Moment
The learning moment was admitting that the best image strategy for this blog is not aggressively image-centric.
The listing page is mostly text. Category pages are mostly text. Article pages prioritize the title, deck, metadata, and body. Images are supported, optimized, cached, and safe, but they are not allowed to dominate the architecture.
The tests reflect the same posture:
getOptimizedStrapiImageUrl returns placeholders when cover data is missing
it prefers requested Strapi formats when available
it falls back across known formats before using the original URL
local Strapi uploads are proxied in development
the production-only proxy block returns 403
non-allowlisted absolute image URLs are rejected
path traversal attempts are rejected
failed image fetches fall back to the placeholder
Those are not glamorous tests. They are the tests you want when a CMS, an optimizer, and a public route all meet at the same boundary.
Principle
My rule now is:
Optimize the image pipeline, not just the image file.
A good image setup is not only AVIF and WebP. It is a chain of boring decisions:
allow only known image origins
use CMS-generated formats before falling back to originals
pass realistic sizes so the browser and optimizer can choose sanely
reserve priority loading for genuinely above-the-fold images
cache optimized output with explicit headers
keep local development close to production without weakening production security
test proxy behavior like a security boundary, not like a UI helper
I am deliberately not claiming an 80% bandwidth reduction here.
Maybe some pages will see that when a huge original upload becomes a smaller responsive WebP or AVIF variant. Maybe some will not. The honest win in this repo is not a universal percentage. It is that the system has fewer ways to surprise me.
That matters more.
The fastest image is often the one you did not put on the page. The safest image proxy is the one with almost no freedom. The best CDN strategy is often the one where the framework cache, origin allowlist, and page rendering model all agree with each other.
Keep the pipeline boring. Spend the creativity on the writing.