# Dynamic OG Images for a Vite SPA with Convex

# 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

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

```bash
# 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](https://www.opengraph.xyz/) · [LinkedIn Inspector](https://www.linkedin.com/post-inspector/) · [Facebook Debugger](https://developers.facebook.com/tools/debug/)

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