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, "&")
.replace(/"/g, """)
.replace(/</g, "<")
.replace(/>/g, ">");
}
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:
- Fetches the article data from your API in parallel with the SPA’s
index.html - Injects the correct
og:title,og:description,og:imagetags into the<head> - 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 remotewhen deploying sonpm installruns on Azure’s Linux environment - Return
Content-Lengthexplicitly — 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:
- Add a CNAME record:
img.yourdomain.com→ yourazurewebsites.netURL (proxied ✅) - 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.