Serving Open Graph to Bots at hey.xyz
Published at Apr 20 2025

At hey.xyz, we built a client-side SPA using Vite and React Router 7. But this created a challenge - how do you serve proper metadata to social media crawlers and search engines when your app renders client-side?

Our solution was to create a dual-architecture system:

  1. Main Web App: CSR app built with Vite + React Router 7 hosted on Cloudflare Pages
  2. OG Service: Minimal SSR Next.js app hosted on Railway that only renders metadata, no UI

The secret sauce is a Cloudflare Worker that acts as our traffic cop. It checks incoming requests for bot user agents, and:

This is the Cloudflare Interface where we connect the Worker with the Web app.

Cloudflare Routes

We've set a generous cache policy (30 days) for bot responses since our metadata doesn't change often.

This approach lets us keep our main app fast and interactive for actual users while still having proper social sharing and SEO. No need to sacrifice the benefits of a modern CSR app just to satisfy crawlers.

Here is the flow diagram of how things work.

Flow Diagram

Honestly, this pattern is so clean and efficient - we're never going back to server-rendering our entire app just for bots.

This strategy helps us deliver millions of requests in a cost-efficient way, keeping our infrastructure costs low while providing the best experience for both users and search engines.

PS: Here is our Cloudflare worker code that checks all requests:

const botRegex = /(Bot|Twitterbot|facebookexternalhit|LinkedInBot|Slackbot|Discordbot|TelegramBot|WhatsApp|Googlebot|Bingbot|Applebot)/i;

export default {
  async fetch(request) {
    const ua = request.headers.get("user-agent") || "";

    if (!botRegex.test(ua)) {
      return fetch(request);
    }

    const url = new URL(request.url);
    const target = `https://og.hey.xyz${url.pathname}${url.search}`;
    const rewritten = new Request(target, {
      method: request.method,
      headers: request.headers,
      body: ['GET', 'HEAD'].includes(request.method) ? null : await request.text(),
      redirect: "follow",
    });

    const resp = await fetch(rewritten);
    const headers = new Headers(resp.headers);
    headers.set("Cache-Control", "public, max-age=2592000, immutable");

    return new Response(resp.body, {
      status: resp.status,
      statusText: resp.statusText,
      headers,
    });
  }
};