Dynamic OG Images for a Vite SPA with Convex

Just a guy who loves to write code and watch anime.
Search for a command to run...

Just a guy who loves to write code and watch anime.
No comments yet. Be the first to comment.
Qualities that make outstanding builders.

Bipedal vs quadrupedal: how many legs This is about the number of legs the creature walks on. Bipedal. Two legs. Humans, ostriches, T-rex, kangaroos, most fantasy humanoids. Quadrupedal. Four legs. Do

Intro You hear "lerp" and "smoothstep" everywhere in game dev. They sound like math jargon. They're not. Both are small tools that do the same job: smoothly move from one value to another. The problem

What is Kinematics Kinematics is the math field. It's the study of how things move without worrying about forces (which would be dynamics). FK and IK are the two branches: forward kinematics and inver

Intro Textures are usually the biggest cost in a 3D scene. Memory, bandwidth, and load time all get eaten by them. Resizing them is the obvious lever. There's more. This post is about the less obvious

Vite SPA serves one index.html for all routes. Social crawlers (X, Discord, Slack, LinkedIn, etc.) read the first HTML response and don't run JavaScript. So every shared link shows the same generic preview.
Don't do full SSR. Just intercept crawler requests and serve them separate HTML with the right OG tags. Normal users still get the SPA.
Crawler hits /u/username
→ Vercel middleware detects bot user-agent
→ Fetches Convex GET /og-meta?username=...
→ Convex returns HTML with dynamic OG tags
→ og:image points to Convex GET /og-image?username=...
→ Convex HTTP route calls Node action
→ Node action renders PNG with satori + @resvg/resvg-wasm
→ Crawler builds the preview card
Normal user hits /u/username
→ Middleware does nothing
→ SPA loads normally
middleware.ts (Vercel, project root)
Matches your dynamic routes (e.g. /u/*)
Checks user-agent against known bot strings
Bot request → fetch Convex /og-meta, return that HTML
Normal request → do nothing, SPA serves as usual
convex/http.ts — Three endpoints:
GET /og-meta — Returns tiny HTML with dynamic OG tags. Used by middleware.
GET /og-image — Returns PNG. This is what og:image points to.
GET /og-data — Returns JSON. Useful for debugging.
convex/og_actions.tsx — The image renderer:
Uses "use node" directive
Internal action that renders the OG image
Renders JSX → SVG via satori → PNG via @resvg/resvg-wasm
Caches font fetches and WASM init in module scope for warm executions
Separate share query — Important design decision:
Create a separate query for OG data that's more lenient on freshness
Your main page might re-fetch if data is older than X hours
Your OG query should return stored data even if slightly stale
A slightly stale preview is much better than no preview
Using: satori, @resvg/resvg-wasm, p-retry
Removed: @vercel/og, @resvg/resvg-js, Vercel api/og route
| Attempt | Why it failed |
|---|---|
Vercel /api/og route |
SPA catch-all rewrite in vercel.json swallowed it. /api/og kept returning index.html instead of the function. Even rewrite ordering fixes didn't reliably work in Vite-on-Vercel. |
| ESM imports in Vercel function | Got SyntaxError: Cannot use import statement outside a module. Vercel ran the function in CommonJS mode. |
| JSX in Vercel function | Build failed with missing --jsx flag. Vercel build pipeline didn't compile the API route like the main app. |
@vercel/og inside Convex |
Expected bundled font assets on disk that Convex didn't include. |
@resvg/resvg-js in Convex |
Uses native .node binaries. Convex bundling doesn't support native binaries. |
| Google Fonts CSS endpoint | Returned woff2 files. Satori threw Unsupported OpenType signature wOF2. |
Font issue: Don't use the Google Fonts CSS endpoint. Fetch direct .ttf files instead.
Resvg issue: Use @resvg/resvg-wasm, not @resvg/resvg-js. WASM bundles cleanly in Convex.
Routing issue: Move the image out of Vercel entirely. Serve it from Convex HTTP action. Removes the SPA routing conflict completely.
OG metadata: Short cache headers. Ready states get longer cache than pending/error.
OG image: Ready states get public, max-age=86400, stale-while-revalidate=43200. Non-ready gets shorter.
Data freshness: Main page and preview should use different freshness rules on purpose. Page stays fresh. Preview stays available.
# Bot HTML — should show dynamic og tags
curl -s -A "Twitterbot/1.0" "https://yourdomain.com/u/USERNAME" | rg "og:title|og:description|og:image|twitter:image"
# Image route — should return 200 + content-type: image/png
curl -I "https://your-app.convex.site/og-image?username=USERNAME"
# Normal user — should return SPA HTML
curl -I "https://yourdomain.com/u/USERNAME"
# Missing user — should still return valid fallback OG tags
curl -s -A "Twitterbot/1.0" "https://yourdomain.com/u/fakename123456" | rg "og:title|og:description|og:image"
Preview validators: opengraph.xyz · LinkedIn Inspector · Facebook Debugger
Check bot HTML first. If wrong, fix middleware or /og-meta before touching images.
Check image route directly. If not returning image/png, problem is in Convex rendering.
Check share state via /og-data. Is the data ready, pending, or error?
If image returns 500: Likely font fetch failure, WASM init failure, satori parsing error, or unbundleable dependency.
If someone wants to move the image back to Vercel: The /api/* vs SPA routing conflict will come back. Convex is the cleaner home for this.
Pre-generate the PNG after data is saved, store in Convex file storage, point og:image to the stored file directly. Removes on-demand render work.
Store fonts/WASM closer to runtime to reduce external fetches.