Skip to main content

Command Palette

Search for a command to run...

Dynamic OG Images for a Vite SPA with Convex

Published
4 min read
Dynamic OG Images for a Vite SPA with Convex
T

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 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

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 .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.

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

  1. Check bot HTML first. If wrong, fix middleware or /og-meta before touching images.

  2. Check image route directly. If not returning image/png, problem is in Convex rendering.

  3. Check share state via /og-data. Is the data ready, pending, or error?

  4. If image returns 500: Likely font fetch failure, WASM init failure, satori parsing error, or unbundleable dependency.

  5. 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:image to the stored file directly. Removes on-demand render work.

  • Store fonts/WASM closer to runtime to reduce external fetches.