Back to all posts
Engineering & Design·Sep 10, 2026·4 min read

How We Serve Dynamic OpenGraph Previews on a Pure Vite SPA (Without Next.js)

Samuel Tuoyo
Samuel TuoyoCo-founder & Full Stack Engineer
How We Serve Dynamic OpenGraph Previews on a Pure Vite SPA (Without Next.js)

Single Page Applications built with React and Vite give our users instant navigation, but they present a classic problem when shared on social media. Link preview bots from platforms like WhatsApp, X, Discord, and LinkedIn do not run JavaScript. Here is how we set up a lightweight edge routing system on Vercel to serve dynamic OpenGraph preview cards without rewriting our entire app in Next.js.

Why Vite apps struggle with social link cards

When a human visits Myhappr, their browser downloads our static index.html file and runs our Vite JavaScript bundle. The UI renders in milliseconds, data loads from our API, and page transitions feel instant.

However, social media preview crawlers work differently. When someone pastes a profile link into a WhatsApp chat, Telegram group, Discord server, or a post on X, the platform sends an automated scraper bot to inspect the URL. Crucially, these crawlers do not execute JavaScript. They only read the raw HTML response and search for OpenGraph meta tags.

Because a standard SPA serves the exact same static index.html shell for every single route, all shared links would end up showing the same generic homepage preview image and title instead of the creator's actual profile.

Why full SSR felt like overkill

Migrating to full Next.js SSR was an immediate non-starter for us due to a very real hardware constraint: at the time, my co-founder was developing and maintaining the frontend directly on an Android phone using Termux and Acode (he has since upgraded to a laptop!). Running a heavy Next.js development server on a mobile phone constantly ran out of RAM and crashed.

As our full stack engineer, I took on the task of solving dynamic social previews cleanly without changing our lightweight stack. When looking at our production traffic, full SSR made little sense anyway. Over 99% of our visitors are real humans with modern browsers who get instant page switches from our static CDN-hosted SPA.

Only automated scraper bots actually need raw server-rendered HTML meta tags. Rearchitecting the entire application just to satisfy preview bots would add cold starts, server rendering overhead, and infrastructure costs for the 99.9% of users who do not need it.

Intercepting bots at the edge with Vercel

To solve this cleanly, we split our traffic at the hosting layer using Vercel rewrites. When an incoming request hits our domain, Vercel inspects the User-Agent header.

If the request comes from a known social crawler (such as Twitterbot, the WhatsApp scraper, FacebookExternalHit, or Discordbot), Vercel silently rewrites the request to a dedicated serverless function at /api/og. Regular users continue straight to index.html without any delay.

vercel.json
{
  "source": "/:slug",
  "has": [
    {
      "type": "header",
      "key": "user-agent",
      "value": "(?i).*(facebookexternalhit|Twitterbot|WhatsApp|Discordbot|TelegramBot).*"
    }
  ],
  "destination": "/api/og?username=:slug"
}
How the system works
Incoming Requeste.g. myhappr.com/samueltuoyo
User-Agent Inspectionvercel.json regex at edge CDN
Human
Vercel Edge CDNFast Vite React SPA shell (instant client hydration)
Bot / Crawler
api/og.tsServerless API edge rewrite
Static Routestatic-meta.ts
Creator ProfileFetch User API
Inject Meta into dist/index.htmlCustom title, description & OpenGraph image tags
Cached Response (s-maxage=300)Cached HTML delivered to bot scraper
Rich Social Preview CardWhatsApp, X (Twitter), Discord & Slack

Generating dynamic HTML on the fly

Once the crawler hits our /api/og serverless API function, we check the requested path. If the slug matches a static route like /pricing or /about, we return pre-configured metadata immediately.

For creator profile pages, the serverless API queries our public backend API to fetch their display name, bio, and avatar. Under the hood, rather than constructing an HTML document from scratch, the function reads the production dist/index.html generated during our Vite build.

It then runs regex replacements to swap default OpenGraph and Twitter card tags with the creator's real profile data, while injecting lightweight semantic HTML into the body for text crawlers. Here is a simplified blueprint illustrating the core flow:

api/og.ts
// api/og.ts (Simplified Serverless API Blueprint)
import { readFileSync } from 'fs';
import { join } from 'path';
import type { VercelRequest, VercelResponse } from '@vercel/node';

// Tries multiple candidate paths because __dirname shifts between
// local dev and the Vercel runtime deployment root
function loadIndexHtml(): string {
  const candidates = [
    join(__dirname, '..', 'dist', 'index.html'),
    join(__dirname, '..', '..', 'dist', 'index.html'),
    join(process.cwd(), 'dist', 'index.html'),
  ];
  for (const filePath of candidates) {
    try { return readFileSync(filePath, 'utf-8'); } catch { /* try next */ }
  }
  throw new Error('Unable to locate dist/index.html');
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
  const username = req.query.username as string;

  try {
    // Fetch creator profile data from backend API
    const profile = await fetchProfile(username);

    // Read the compiled dist/index.html produced by Vite
    let html = loadIndexHtml();

    // Inject dynamic OpenGraph meta tags via regex
    html = injectMeta(html, {
      title: `${profile.displayName} on Myhappr`,
      description: profile.bio || 'Check out my profile on Myhappr',
      image: profile.avatar || 'https://myhappr.com/default-og.png',
      url: `https://myhappr.com/${username}`,
    });

    // Return with edge caching headers to reduce API load
    res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=300');
    res.setHeader('Content-Type', 'text/html; charset=utf-8');
    return res.status(200).send(html);
  } catch {
    // Graceful fallback to a minimal pre-built HTML template
    return res.status(200).send(getDefaultTemplate());
  }
}

Dynamic and static route handling across the platform

Our edge routing does not stop at user profiles. We unified metadata generation across our entire platform to handle three distinct route categories:

1. Creator Profiles (/:username): Queries our backend API in real time to fetch the creator's avatar, bio, verified status, and smile price, building customized OpenGraph cards tailored to their brand.

2. Blog Posts (/blog/:slug): Injects exact post titles, author information, read times, published dates, and custom high-resolution banner images for maximum engagement on social feeds.

3. Marketing and Static Pages (/pricing, /brand, /faq, /privacy, /terms): Immediately serves high-converting titles, descriptions, and page-specific preview cards without hitting database APIs.

Furthermore, by including search engine bots like Googlebot, Bingbot, and DuckDuckBot in our edge rewrite rules, search crawlers receive rich, semantically structured HTML and metadata on the initial fetch, giving us full SEO discoverability without running full SSR.

Replicating this on Netlify, Cloudflare, or Nginx

While we implemented our routing rules on Vercel, this exact same pattern works on virtually any modern hosting platform or reverse proxy.

If you host on Netlify, you can use header-based redirects in netlify.toml to route crawler user-agents to a Netlify Function. On Cloudflare Pages or Cloudflare Workers, edge middleware can inspect the User-Agent header and return generated meta tags. Even on a traditional Nginx VPS or Docker setup, a simple regex rewrite rule forwarding bot traffic to a lightweight script achieves identical results.

The practical takeaway

This setup gives us the best of both worlds. Our creators get customized, branded preview cards whenever their links are shared across social apps, while our users keep the fast, fluid experience of a pure client-side React app.

Best of all, the entire mechanism runs on edge rewrites and a single serverless function, keeping our architecture simple and easy to maintain.

Samuel Tuoyo

Samuel Tuoyo

Co-founder & Full Stack Engineer at Myhappr.

Follow @TuoyoS26091 on X

Read next from Team Myhappr

Turn your audience into direct financial supporters.

Myhappr makes it easy for African creators to receive support and tips from anywhere in the world directly to local bank accounts with zero creator fees.

Claim your page