Dynamic OG Images for a Vite SPA with Convex

Just a guy who loves to write code and watch anime.
The Problem
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.
The Solution
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.
Final Architecture
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
The Files
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 HTMLNormal 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 whatog:imagepoints to.GET /og-data— Returns JSON. Useful for debugging.
convex/og_actions.tsx — The image renderer:
Uses
"use node"directiveInternal action that renders the OG image
Renders JSX → SVG via
satori→ PNG via@resvg/resvg-wasmCaches 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
Final Dependency Stack
Using: satori, @resvg/resvg-wasm, p-retry
Removed: @vercel/og, @resvg/resvg-js, Vercel api/og route
What Failed and Why
| 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. |
Key Fixes
Font issue: Don't use the Google Fonts CSS endpoint. Fetch direct
.ttffiles 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.
Caching
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.
Testing Commands
# 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
Debugging Order
Check bot HTML first. If wrong, fix middleware or
/og-metabefore touching images.Check image route directly. If not returning
image/png, problem is in Convex rendering.Check share state via
/og-data. Is the dataready,pending, orerror?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.
Possible Future Improvements
Pre-generate the PNG after data is saved, store in Convex file storage, point
og:imageto the stored file directly. Removes on-demand render work.Store fonts/WASM closer to runtime to reduce external fetches.






