01 · The setup
A creative agency's company-profile site, built on Next.js 15.5.
It ran on Cloudflare Workers through @opennextjs/cloudflare, with an R2 incremental cache and a long-lived regional cache in front. Fast, cheap, and as it turned out, very good at keeping a mistake.
02 · The root cause
The favicon was a prerendered route, and the prerendered body was read as UTF-8 text.
Every byte that isn't valid UTF-8 became the replacement character U+FFFD. The corrupted result was then cached as immutable, so every request got the broken file until the cache entry changed. Nothing in the code looked wrong.
03 · The fix
Keep binaries out of anything that might treat them as text.
- favicons, images, fonts and PDFs go in
public/, served as static assets - route handlers that return binary data are forced dynamic
- on this stack, skip the
icon.*andopengraph-image.*file conventions
// app/report/route.ts — a handler that returns binary data
export const dynamic = "force-dynamic";04 · Verify with a hash
When an asset is wrong only in production, compare bytes, not screenshots.
Hash the file you shipped and the file the live URL returns. If they differ, something between your build and the browser changed it. The same pass also cut the favicon from 88 KB to 9 KB.
shasum -a 256 public/favicon.ico
curl -s https://example.com/favicon.ico | shasum -a 25605 · Lead row first
The same build taught me a pattern for contact forms.
The endpoint validates the request, rate-limits per IP using a salted hash, writes the lead row, and only then sends the email with the PDF and updates the status. If the email provider is down, the lead still exists. Order the steps so the thing you can't lose is saved first.
06 · A ticker that doesn't jump
Small detail, same project.
The logo ticker slows down on hover by changing the Web Animations playbackRate, not the animation duration, so it never jumps position. It's off when the user prefers reduced motion.
