Media on the CDN, code on the origin: a delivery strategy that ages well
Next.js will happily serve your JS bundles from a CDN with one config line. We stopped doing that. Hashed chunks belong to the build that emitted them; images outlive every deploy — and treating both as 'assets' is how a rollback turns into a cache-purge incident.
The standard advice is "put your assets behind a CDN," and in Next.js that advice has a one-line implementation. Set assetPrefix, and every file the build emits — hashed JS chunks, CSS, fonts, images, the lot — gets rewritten to point at the edge. It's satisfying. It shows up on a slide. We ran it that way, and then spent enough deploys debugging problems that had nothing to do with speed to split it in two: media goes to the CDN, application code stays on the origin.
That split is what this site runs today. It's not a performance optimisation — the performance was fine either way. It's a decision about which bytes are allowed to outlive a deploy.
Two kinds of bytes that only look alike
Open the output of a production build and everything in it looks the same: content-addressed filenames, immutable content, safe to cache for a year. chunks/4f2a91c8.js and covers/some-post.svg are both hashed, both never edited in place, both perfect CDN candidates by the usual checklist.
The checklist is missing the thing that matters. A JS chunk is only meaningful to the build that produced it. Its filename is an implementation detail of one specific compile, referenced by an HTML document from that same compile, and it becomes garbage the moment the next build ships. An image is the opposite: it has no relationship to any build at all. The same hero photo is correct across fifty deploys and two framework major versions.
One is coupled to a release. One isn't. Putting both on infrastructure with its own independent cache lifecycle means the coupled one now has two sources of truth about what "current" means.
The deploy is where CDN-hosted code bites
Here's the failure that made the decision for us, and it's entirely mundane. The browser loads HTML from the origin. That HTML references a chunk on the CDN. The edge doesn't have it yet — cold pull zone, or a region that hasn't seen this URL — so it fetches from the origin itself. During a rolling update, "the origin" is not one thing: it's a set of pods, some running the new revision and some still running the old one. The pod that served the HTML and the pod the CDN happens to pull the chunk from do not have to be the same revision.
Most of the time you get away with it. Sometimes a visitor gets a white screen and a ChunkLoadError, which is the single most useless error class in front-end engineering, because by the time anyone looks the deploy has settled and it reproduces for nobody.
Rollback is worse. Reverting a Kubernetes deployment takes seconds and is a solved problem — that's the whole point of running immutable images through GitOps. Reverting a CDN is not the same operation. The edge is still holding objects keyed to URLs, and the purge API is asynchronous and eventually consistent by design. Your recovery path now includes a third-party HTTP call that you cannot roll back if it misbehaves. We are not willing to put a vendor's control plane on the critical path of an incident response.
And the reward for accepting all that is smaller than it looks. The visitor's browser has already opened a connection to your origin — it just pulled the HTML over it, TLS handshake paid, HTTP/2 connection warm and idle. Moving the JS to a second hostname throws that away and buys a fresh DNS lookup, TCP setup and TLS negotiation before the first byte of the first chunk arrives. For an origin already sitting in the same region as most of your audience, edge proximity on a 30KB chunk does not repay a new connection. Belgian SME traffic is Belgian; we are not shaving RTT off Sydney.
What a CDN is genuinely better at
None of the above is an argument against CDNs. It's an argument about which workload they're for — and for media, nothing else comes close.
The win isn't distance. It's that one URL can serve many representations. Our images go through a custom next/image loader that hands sizing and format negotiation to the Bunny edge:
/**
* next/image loader for the Bunny CDN pull zone: resize/format happen at
* the Bunny edge (Optimizer), so the container ships without sharp.
* Non-CDN sources (local /covers, /brand SVGs) pass through untouched.
*/
export default function bunnyLoader({ src, width, quality }: ImageLoaderProps) {
if (!src.startsWith(CDN_URL)) {
return src
}
const url = new URL(src)
url.searchParams.set("width", String(width))
url.searchParams.set("quality", String(quality ?? 80))
url.searchParams.set("format", "auto")
return url.toString()
}images: {
// Resize/AVIF happen at the Bunny edge; no sharp in the container
loader: "custom",
loaderFile: "./lib/image-loader.ts",
},Read that comment again, because it's the actual payoff: no sharp in the container. The phone gets AVIF at 640px, the 5K display gets the full-width original, and our Node process never decodes a pixel, never allocates a 40MB buffer to resize a hero, and never gets OOM-killed doing it. That's not a latency win, it's a capacity win — we removed an entire class of work from the thing that also has to answer requests. On the clusters we run for clients, that difference shows up as memory limits we don't have to keep raising.
This is also the half of Core Web Vitals that infrastructure can actually fix. LCP on a content site is almost always the hero image, and "correct format, correct width, served from the edge" is most of that battle — while TTFB, the other half, is a rendering-architecture question that a CDN can't help with at all.
The split, in code
Once you accept the distinction, the implementation is about fifteen lines. A single helper decides what gets the CDN host prefixed onto it:
/**
* Prefix root-relative public/ MEDIA paths with the CDN host when the build
* is configured for CDN delivery (NEXT_PUBLIC_ASSET_PREFIX, baked at build
* time). JS/CSS are never CDN-served — application code ships with the app.
* Absolute URLs (already-CDN media from `pnpm media:upload`) pass through.
*/
const prefix = process.env.NEXT_PUBLIC_ASSET_PREFIX ?? ""
export function asset(path: string): string {
return path.startsWith("/") ? `${prefix}${path}` : path
}Note what this is not: it isn't Next's assetPrefix. That option is framework-wide and applies to the build output — exactly the bytes we've decided to keep at home. Ours is an application-level helper, called explicitly at the ~dozen places that render media, and completely inert for everything else. Every <Image> on a post card, project cover, team photo or brand logo goes through it; nothing else does.
The rule is then enforced a second time, in the uploader, so a mistake in a component can't leak code onto the CDN:
// public/** minus anything that is application code or a route-owned file.
} else if (
!entry.name.startsWith(".") &&
!/\.(js|mjs|css|map|txt|xml)$/i.test(entry.name)
) {
out.push(full)
}.txt and .xml are in that exclusion list for a different reason than .js is: robots.txt and sitemap.xml have to be served from the site's own origin to be worth anything. A sitemap on a CDN subdomain is a sitemap for the CDN subdomain.
The CI ordering is the safety property
The most important part of this setup isn't a config value, it's the order two pipeline steps run in. Media is pushed to Bunny before the image that points at Bunny is built:
sync-media:
commands:
- pnpm build
- cd apps/web && node scripts/media-sync.ts
when:
- event: push
branch: main# main images serve assets from the CDN (sync-media has already populated it)
BUILD_ARGS="--build-arg NEXT_PUBLIC_ASSET_PREFIX=https://cdn.codeagency.be"The prefix is a Docker build arg, baked into the bundle at compile time, and it is set only for pushes to main — and only on a pipeline where the sync step has already succeeded. A build that points at the CDN cannot ship before the CDN has the files. That's the whole invariant, and it's expressed as a depends_on, not as a comment asking people to remember.
The corollary is just as useful: PR builds leave the arg empty, so every preview instance serves its own media from its own origin. A preview is fully self-contained. It can't be broken by production edge cache, it can't poison production edge cache, and reviewing a PR that changes an image doesn't require anyone to purge anything or wonder whether they're looking at the new one. Given that PR previews are how we review changes at all, a preview that shares mutable state with production would defeat the point.
Two media lifecycles, two cache rules
Media itself splits again, and this is the detail most teams get wrong — they pick one caching policy for "the CDN" and then discover it's wrong for half their files.
Repo media — covers, brand SVGs, tech logos — lives at stable, human-readable paths like /covers/media-cdn-code-origin.svg. Stable paths mean replacement happens in place, which means the edge is serving a stale object the moment you change one. So the sync step diffs local SHA-256 against the checksum Bunny reports in its directory listing, uploads only what changed, and purges exactly what it replaced:
const previous = existing.get(remotePath)
if (previous === sha) {
skipped++
continue
}
await upload(remotePath, body)
if (previous) {
replaced.push(remotePath) // purged after upload — stale at the edge otherwise
}Content media — photos in a post, client screenshots, anything editorial — goes up through pnpm media:upload, which content-addresses the path and prints back a URL to paste into frontmatter:
const hash = createHash("sha256").update(content).digest("hex").slice(0, 12)
const key = `media/${hash}/${name}`Those URLs are immutable by construction. Different bytes produce a different path, so nothing is ever replaced, so nothing ever needs purging, so they can be cached at the edge effectively forever. Re-running the upload on an unchanged file is a no-op that prints the same URL.
Same CDN, two policies: purge-on-replace for the paths the repo owns, never-purge for the paths content owns. Deciding that per-lifecycle instead of per-provider is what keeps the cache TTL honest.
The rule we actually apply
Ask one question about any byte you're about to put behind a CDN: does it outlive the build that produced it?
If no — hashed chunks, CSS, source maps, anything the compiler named — it ships with the application, on the origin, and its cache lifetime is the deploy. You get atomic releases, rollbacks that are one kubectl operation, and no vendor in your incident path.
If yes — images, video, fonts, downloadable documents — it belongs at the edge, cached hard, transformed there rather than in your container.
It's a boring split. That's the recommendation. Six months and forty deploys from now, nobody on the team has to think about it, nobody purges a cache to fix a white screen, and the CDN is doing the one job it's unambiguously best at.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.