Oliver White
15 May 2026 · 6 min read
Every archived contest on AI Art Arena has a unique social preview — the winning artwork, the vote count, the contest number. When the page is shared on Twitter or pasted in Slack, the preview shows the actual winner rather than a generic site thumbnail. Building this with next/og is the right tool, but the edge runtime has constraints that the official documentation glosses over. Here is every one of them.
This is part of the Directed Output build log. The methodology — how the refinement loop turns undocumented constraints into working solutions — is at /process.
Every API route in this project uses the /api/v1/ prefix — it is the versioning convention and it signals that the endpoint is a typed, structured API that accepts and returns JSON. The OG image route returns a PNG, not JSON. It is a public media endpoint, not an API. Putting it at /api/v1/og would be misleading. It lives at /api/og/ — same /api/ prefix, different intent.
| Constraint | What breaks | Fix |
|---|---|---|
| No fs module | Cannot read font files from disk | Fetch the font from a public URL or from /public/ via fetch() |
| CSS: flexbox only | Grid, position:absolute, many properties unsupported | Satori (the underlying renderer) only supports a subset of CSS — plan layouts in flexbox |
| No Node.js crypto | crypto.createHash() does not exist | Use Web Crypto API: await crypto.subtle.digest('SHA-256', buffer) |
| Remote images | Cannot use an <img> src directly from Supabase Storage | Fetch the image, convert to base64 data URL, use that as src |
| Font loading | Fonts must be ArrayBuffer, not file paths | fetch('/fonts/Syne-Bold.ttf').then(r => r.arrayBuffer()) |
Edge OG image routes are cached aggressively by Vercel. The first request for a given contest number generates the image and pays the fetch cost. Every subsequent share serves the cached version from the edge. For archived contests that never change, this generation cost is paid exactly once per contest.
The first version used a local font file via fs.readFileSync. It compiled. It threw a runtime error on the first request because fs does not exist in the edge runtime. The second version fetched the font from a URL. It worked locally but timed out in production because the fetch was hitting the production URL during a cold start — a circular dependency. The third version fetched the font from a relative path using the NEXT_PUBLIC_SITE_URL environment variable, which resolved correctly in both environments. That is three iterations to get font loading right — none of which were caught by TypeScript or the test suite.
This is the pattern that Directed Output is built around. The first response gets you to a version that compiles. The second gets you to a version that works in development. The third gets you to a version you actually understand and can maintain. Stopping at the first working version means you are one cold start away from a production failure.
Built with this methodology
The next/og ImageResponse API looks simple until you hit the edge runtime. No fs module. No Node.js crypto. CSS limited to flexbox. Font loading via fetch only. Remote images need base64 encoding. Here is every gotcha encountered building contest preview images for AI Art Arena.
From the build log