When building a React SPA that needs proper Open Graph previews on WhatsApp, you run into a fundamental problem — React apps are client-rendered, so crawlers see an empty <div id="root"></div> with generic meta tags instead of article-specific OG data.

This post covers the full solution: dynamic OG tag injection via a Cloudflare Worker, image compression via an Azure Function, and the subtle bugs that break iOS WhatsApp specifically.

The Stack

  • React SPA hosted on a CDN (AWS Amplify, Azure Static Web Apps, etc.)
  • Cloudflare Worker for dynamic OG tag injection
  • Azure Function + Sharp for image compression
  • Share URLs in the format: https://share.yourdomain.com/article/<uuid>

Part 1: Cloudflare Worker for OG Tag Injection

The Worker intercepts requests to /article/*, fetches article data from your API, and injects OG tags into the HTML before returning it to the crawler.

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);

    const articleMatch = url.pathname.match(/^\/article\/([a-f0-9-]+)$/i);
    if (!articleMatch) return fetch(request);

    const articleId = articleMatch[1];

    const [articleRes, htmlRes] = await Promise.all([
      fetch(`https://api.yourdomain.com/api/v1/posts/${articleId}/public`, {
        headers: { "Accept": "application/json" },
        cf: { cacheEverything: true, cacheTtl: 300 },
      }),
      fetch(request),
    ]);

    if (!articleRes.ok || !htmlRes.ok) return htmlRes;

    const { data: article } = await articleRes.json();
    const html = await htmlRes.text();

    const title = article.title ?? "My App";
    const rawDescription = article.excerpt ?? "";
    const description = rawDescription.length > 200
      ? rawDescription.slice(0, 197) + "..."
      : rawDescription;
    const image = article.coverImageUrl ?? "https://yourdomain.com/assets/default.png";
    const canonicalUrl = article.shareUrl ?? request.url;

    const ogTags = `
    <meta property="og:type" content="article" />
    <meta property="og:url" content="${escapeHtml(canonicalUrl)}" />
    <meta property="og:title" content="${escapeHtml(title)}" />
    <meta property="og:description" content="${escapeHtml(description)}" />
    <meta property="og:image" content="${escapeHtml(image)}" />
    <meta property="og:image:width" content="1200" />
    <meta property="og:image:height" content="630" />
    <meta property="og:image:type" content="image/jpeg" />
    <meta property="og:site_name" content="My App" />
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:title" content="${escapeHtml(title)}" />
    <meta name="twitter:description" content="${escapeHtml(description)}" />
    <meta name="twitter:image" content="${escapeHtml(image)}" />`;

    let newHtml = html
      .replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(title)}</title>`)
      .replace(/<!--\s*Open Graph[^]*?(?=<!--\s*Mobile App|<script)/i, "")
      .replace(/<!--\s*Twitter[^]*?(?=<!--\s*Mobile App|<script)/i, "")
      .replace("</head>", `${ogTags}\n  </head>`);

    // Critical: explicitly delete origin headers to avoid duplicates
    const originHeaders = Object.fromEntries(htmlRes.headers);
    delete originHeaders['content-type'];
    delete originHeaders['cache-control'];

    return new Response(newHtml, {
      status: htmlRes.status,
      headers: {
        ...originHeaders,
        "Content-Type": "text/html; charset=UTF-8",
        "Cache-Control": "public, max-age=300, stale-while-revalidate=60",
      },
    });
  },
};

function escapeHtml(str) {
  return String(str)
    .replace(/&/g, "&amp;")
    .replace(/"/g, "&quot;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}

Why this works

The Worker sits between the CDN and the crawler. When WhatsApp’s facebookexternalhit bot (yes, WhatsApp uses Facebook’s crawler) requests a URL, the Worker:

  1. Fetches the article data from your API in parallel with the SPA’s index.html
  2. Injects the correct og:title, og:description, og:image tags into the <head>
  3. Returns the modified HTML with proper headers

The browser user sees no difference — the SPA hydrates and renders normally. But the crawler now sees the correct meta tags.

Part 2: Image Compression with Azure Function + Sharp

WhatsApp rejects OG images above ~600KB. If your users are uploading high-res cover images, you need a compression proxy.

Key requirements

  • Deploy on Linux (Sharp uses native binaries)
  • Use --build remote when deploying so npm install runs on Azure’s Linux environment
  • Return Content-Length explicitly — chunked transfer encoding causes WhatsApp to reject the image
const { app } = require("@azure/functions");
const sharp = require("sharp");

const ALLOWED_HOSTS = [
  "yourstorage.blob.core.windows.net",
];

const TARGET_SIZE_BYTES = 300 * 1024; // 300KB
const OG_WIDTH = 1200;
const OG_HEIGHT = 630;

app.http("ogImageProxy", {
  methods: ["GET"],
  authLevel: "anonymous",
  route: "ogimageproxy",
  handler: async (request, context) => {
    const imageUrl = new URL(request.url).searchParams.get("url");
    if (!imageUrl) return { status: 400, body: "Missing ?url= param" };

    let parsedUrl;
    try { parsedUrl = new URL(imageUrl); }
    catch { return { status: 400, body: "Invalid URL" }; }

    if (!ALLOWED_HOSTS.includes(parsedUrl.hostname)) {
      return { status: 403, body: "Host not allowed" };
    }

    const res = await fetch(imageUrl);
    if (!res.ok) return { status: 502, body: "Image fetch failed" };

    const originalBuffer = Buffer.from(await res.arrayBuffer());
    const originalSize = originalBuffer.byteLength;

    if (originalSize < TARGET_SIZE_BYTES) {
      const meta = await sharp(originalBuffer).metadata();
      return buildResponse(originalBuffer, `image/${meta.format}`, originalSize, originalSize, "none");
    }

    const qualities = [80, 65, 50, 35, 20];
    for (const quality of qualities) {
      const compressed = await sharp(originalBuffer)
        .resize(OG_WIDTH, OG_HEIGHT, { fit: "inside", withoutEnlargement: true })
        .jpeg({ quality, progressive: true, mozjpeg: true })
        .toBuffer();

      if (compressed.byteLength < TARGET_SIZE_BYTES) {
        return buildResponse(compressed, "image/jpeg", originalSize, compressed.byteLength, `jpeg-q${quality}`);
      }
    }

    const fallback = await sharp(originalBuffer)
      .resize(800, 418, { fit: "cover" })
      .jpeg({ quality: 40, progressive: true })
      .toBuffer();

    return buildResponse(fallback, "image/jpeg", originalSize, fallback.byteLength, "800x418-q40");
  },
});

function buildResponse(buffer, contentType, originalSize, finalSize, method) {
  return {
    status: 200,
    isRaw: true,
    body: buffer,
    headers: {
      "Content-Type": contentType,
      "Content-Length": String(buffer.byteLength),
      "Cache-Control": "public, max-age=86400, stale-while-revalidate=3600",
      "X-Original-Size": `${(originalSize / 1024).toFixed(1)}KB`,
      "X-Final-Size": `${(finalSize / 1024).toFixed(1)}KB`,
      "X-Compression-Method": method,
      "Access-Control-Allow-Origin": "*",
    },
  };
}

Deploy

func azure functionapp publish <your-function-app-name> --build remote

Put Cloudflare in front of the Azure Function

To avoid cold start timeouts (Azure Consumption plan goes cold after ~5 min), proxy the function through Cloudflare:

  1. Add a CNAME record: img.yourdomain.com → your azurewebsites.net URL (proxied ✅)
  2. Add a Cache Rule: img.yourdomain.com/api/ogimageproxy* → Cache Everything → Edge TTL 7 days

Part 3: Bugs That Break iOS WhatsApp Specifically

This is where the real engineering time went. Everything worked on Android and desktop — but iOS WhatsApp silently dropped previews.

Bug 1: Duplicate Content-Type header

Symptom: iMessage previews work, iOS WhatsApp previews don’t. Android WhatsApp works fine.

Cause: Spreading origin headers and then setting Content-Type merges them instead of replacing:

content-type: text/html, text/html;charset=UTF-8  ← malformed

Fix: Explicitly delete origin headers before spreading:

const originHeaders = Object.fromEntries(htmlRes.headers);
delete originHeaders['content-type'];
delete originHeaders['cache-control'];

iOS WhatsApp’s server-side fetcher applies strict HTTP header validation and silently drops previews on malformed headers. Android WhatsApp is more lenient.

Bug 2: Missing og:image dimensions

Without og:image:width and og:image:height, Facebook/WhatsApp processes the image asynchronously. The first share shows no preview.

<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/jpeg" />

Bug 3: Chunked Transfer-Encoding on image response

WhatsApp’s crawler requires a Content-Length header on image responses. Azure Functions returns chunked encoding by default unless you explicitly set isRaw: true and Content-Length in the response.

Bug 4: Cloudflare Bot Fight Mode blocking Meta crawlers

If you have Super Bot Fight Mode enabled, facebookexternalhit gets challenged. Create a WAF skip rule:

(http.user_agent contains "facebookexternalhit") or
(http.user_agent contains "Facebot") or
(http.user_agent contains "WhatsApp")

Action: Skip → all WAF rules + Super Bot Fight Mode. Place this rule at position #1.

Debugging Checklist

# Simulate iOS WhatsApp crawler
curl -sI -A "WhatsApp/2.24.6.77 i" "https://share.yourdomain.com/article/<id>"

# Simulate Facebook/Meta crawler  
curl -sI -A "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" "https://share.yourdomain.com/article/<id>"

# Check OG tags as seen by crawler
curl -s -A "facebookexternalhit/1.1" "https://share.yourdomain.com/article/<id>" | grep -i "og:"

# Check image response
curl -sI "https://img.yourdomain.com/api/ogimageproxy?url=<encoded-image-url>"

# Verify image is valid
curl -s "https://img.yourdomain.com/api/ogimageproxy?url=<encoded-image-url>" | file -

Use the Facebook Sharing Debugger to force a re-scrape after making changes.

Pre-cache on publish

To avoid cold starts on the first share, hit the Graph API scrape endpoint when an article is published:

async function precacheOgTags(articleId) {
  const articleUrl = `https://share.yourdomain.com/article/${articleId}`;
  const debuggerUrl = `https://graph.facebook.com/?id=${encodeURIComponent(articleUrl)}&scrape=true&access_token=${FB_ACCESS_TOKEN}`;
  await fetch(debuggerUrl, { method: "POST" });
}

This warms Meta’s cache so WhatsApp previews work instantly from the first share.


This solution was engineered by TESARK for a client application requiring reliable social sharing previews across WhatsApp, iMessage, Telegram, and LinkedIn. If you’re building a platform that needs edge-level content transformation, get in touch.