OG Tags in a React SPA Without Next.js: A Lightweight SSR Proxy with Express
The Problem
At AR Spatially, we ran into a frustrating issue: whenever someone shared a link to a space or an object, messengers wouldn’t generate a proper preview. We wanted share links to look good — but at best, they’d pull the title and description from the landing page’s homepage. A bit of research made the root cause obvious: OG tags. Any SPA returns the exact same HTML for every single route.
This is what Telegram, Facebook, Slack, and every other messenger sees when they try to generate a preview for a React SPA:
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="root"></div>
<script src="/assets/index-Bx92kL.js"></script>
</body>
</html>
Bots can’t run JavaScript. They make a GET request, parse the HTML, and look for
<meta property="og:*">
tags. No tags — no card, or an empty one at best.
The canonical solution is to migrate to Next.js. That sounds perfectly reasonable until you’re staring at a project with a year of history, dozens of components, and a team with deadlines. At some point it becomes clear that rewriting the app for SSR is a multi-month project in its own right — and OG tags were needed now.
So we went a different route. Here’s what we did.
Why SPAs and Link Previews Don’t Get Along A bit of background before getting to the solution — you need this context to understand why a server is necessary at all.
When you share a link in a messenger, this is what happens:
- The messenger makes a GET request to the URL
- It receives the HTML
- It parses looking for og:title, og:description, og:image
- It renders the card
The key part: social media and messenger bots don’t execute JavaScript. Googlebot can render JS, but that’s the exception. Facebook, Telegram, Slack, LinkedIn, Twitter/X, WhatsApp — they all want ready-made HTML.
For SPAs, this is a dead end. All the content is generated by client-side JS after the page loads. The server returns a skeleton — a
<div id="root">
and a bundle. For a bot, that skeleton is the entire page.
The Idea: SSR Only Where It’s Actually Needed
We asked ourselves: why render the whole React app on the server if all bots need is a handful of tags?
Here’s the core insight: bots don’t need an interactive UI. They need a title, a description, and an image. That’s it.
So the problem reduces to this:
Detect that a request is coming from a bot Hit the API to fetch data for the relevant entity Generate tags Inject them into the already-built dist/index.html Return the enriched HTML For real users — just serve index.html as-is. No Server-Side Rendering, no React on the server.
The deployment setup looks like this:
Request
│
▼
nginx (80/443)
│
├── /assets/*, /images/* → static files (dist/)
│
└── /* → Express SSR-proxy (port 3001)
│
├── User-Agent = bot?
│ ├── yes → fetch API → inject OG → enriched HTML
│ └── no → dist/index.html (as-is)
│
└── /ingest/* → PostHog proxy (bonus, more on this later)
We call this “lazy SSR” — the server does exactly as much as it needs to, and not a byte more.
Server Architecture
The server is written in TypeScript and runs on Express. The structure is intentionally as simple as possible:
server/
ssr-server.ts # entry point, Express app
pages/
object.ts # object page handler
space.ts # space page handler
default.ts # fallback for everything else
utils/
bot-detection.ts # bot detection via User-Agent
og-tags.ts # meta tag generation
html.ts # index.html manipulation
api.ts # internal API requests
The entry point registers routes in the right order:
// Static files — no HTML, index: false is critical here
app.use(express.static(distPath, { index: false }));
// Specific routes first
app.get("/app/objects", handleObjectSSR);
app.get("/app/items/:id", handleItemSSR);
// Fallback — everything else
app.use(handleDefault);
The index: false flag isn't accidental — without it, Express would start serving index.html for the root path on its own, and the specific handlers would never fire.
Part 1: Bot Detection
The simplest piece — and simultaneously the least reliable, which we’ll be upfront about at the end.
const BOT_PATTERNS: RegExp[] = [
/bot/i,
/crawler/i,
/spider/i,
/facebookexternalhit/i,
/twitterbot/i,
/telegrambot/i,
/linkedinbot/i,
/whatsapp/i,
/slackbot/i,
/discordbot/i,
/googlebot/i,
];
export const isBot = (userAgent: string | undefined): boolean => {
if (!userAgent) return false;
return BOT_PATTERNS.some((pattern) => pattern.test(userAgent));
};
The logic is straightforward: check the User-Agent against a list of patterns. If there’s a match — it’s a bot, serve enriched HTML. Otherwise — plain SPA.
Why this works in practice: every major bot identifies itself honestly in the User-Agent. Facebook sends facebookexternalhit, Telegram sends TelegramBot, and so on. None of them try to impersonate Chrome.
Part 2: Meta Tag Injection
This is where all the magic happens. Just two functions, and the job is done.
Generating the tags:
interface OGMetaTagsParams {
title: string;
description: string;
imageUrl: string;
pageUrl: string;
type?: "website" | "article";
}
export const generateOGMetaTags = ({
title,
description,
imageUrl,
pageUrl,
type = "website",
}: OGMetaTagsParams): string => {
return `
<meta property="og:title" content="${escapeHtml(title)}" />
<meta property="og:description" content="${escapeHtml(description)}" />
<meta property="og:image" content="${escapeHtml(imageUrl)}" />
<meta property="og:url" content="${escapeHtml(pageUrl)}" />
<meta property="og:type" content="${type}" />
<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(imageUrl)}" />
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}" />
`;
};
Injecting into the HTML:
export const injectMetaTags = (html: string, metaTags: string): string => {
const headClosePattern = /<\/head>/i;
if (!headClosePattern.test(html)) {
// Paranoid fallback — in case of malformed HTML
return html.replace(/<\/html>/i, `${metaTags}\n</html>`);
}
return html.replace(headClosePattern, `${metaTags}\n</head>`);
};
Pay attention to escapeHtml — this isn't optional, it's mandatory. Titles and descriptions come from user-generated content. If something like "><" slips in there, you've got an XSS vulnerability sitting right in your without proper escaping. The function handles &, <, >, ", and '.
How this looks in a handler:
export const handleItemSSR = async (
req: Request,
res: Response,
next: NextFunction,
) => {
const itemId = req.query.itemId as string;
if (!itemId) return next();
// Regular users get regular HTML
if (!isBot(req.get("user-agent"))) {
return res.send(getIndexHtml());
}
try {
const item = await getItemData(itemId);
if (!item) return next(); // API didn't respond — fall through to default
const metaTags = generateOGMetaTags({
title: item.title || "My App",
description: item.description?.slice(0, 150) ?? "",
imageUrl: resolveImageUrl(item.preview?.url, req),
pageUrl: `${req.protocol}://${req.get("host")}${req.originalUrl}`,
type: "article",
});
res.send(injectMetaTags(getIndexHtml(), metaTags));
} catch {
next(); // Error — serve default HTML, don't crash
}
};
Every handler follows the same shape: validate params → check for bot → fetch API → inject → respond. If anything goes wrong at any step — next() and fall back to default HTML.
Part 3: Fetching Data
The server makes a request to the internal API to fetch entity data. Nothing fancy, just don’t skip error handling:
export const getItemData = async (
itemId: string,
): Promise<ItemData | null> => {
try {
const response = await fetch(
`${getApiBaseUrl()}/gateway/item/get?itemId=${itemId}`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
},
);
if (!response.ok) return null;
const data = await response.json() as ApiResponse<ItemData>;
return data?.data ?? null;
} catch {
return null;
}
};
This function always returns either data or null. It never throws. That's intentional — if the API is down, the server should keep running and return plain HTML, not a 500.
Bonus: PostHog Reverse Proxy and Safari ITP
While adding OG tag support to the server, we ran into another issue: analytics was breaking in Safari.
Since Safari 14+, Apple has aggressively restricted third-party cookies and requests to tracking domains — this is called ITP (Intelligent Tracking Prevention). PostHog, connected the standard way via CDN, was flaky in Safari.
The fix is simple: proxy PostHog requests through your own domain. From the browser’s perspective, it’s a same-origin request — Safari leaves it alone.
const HOP_BY_HOP = new Set([
"connection", "keep-alive", "transfer-encoding",
"upgrade", "te", "trailer",
"proxy-authenticate", "proxy-authorization",
]);
app.use("/ingest", (req, res) => {
// Strip hop-by-hop headers — they can't be forwarded (RFC 2616 §13.5.1)
const forwardHeaders = Object.fromEntries(
Object.entries(req.headers).filter(
([k]) => !HOP_BY_HOP.has(k.toLowerCase()),
),
);
forwardHeaders.host = "eu.i.posthog.com";
const proxyReq = httpsRequest(
{
hostname: "eu.i.posthog.com",
port: 443,
path: req.url,
method: req.method,
headers: forwardHeaders,
},
(proxyRes) => {
const responseHeaders = Object.fromEntries(
Object.entries(proxyRes.headers).filter(
([k]) => !HOP_BY_HOP.has(k.toLowerCase()),
),
);
res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);
proxyRes.pipe(res);
},
);
proxyReq.on("error", () => {
if (!res.headersSent) res.status(502).end();
});
req.on("aborted", () => proxyReq.destroy());
req.pipe(proxyReq);
});
Hop-by-hop headers are headers that apply only to a single connection and shouldn’t be forwarded. If you don’t filter them out, the proxy can break the connection on the PostHog side. This is RFC 2616, section 13.5.1 — not the most thrilling read, but worth knowing.
The result: analytics started working in Safari without a single change to client-side code. Just point /ingest in the PostHog SDK config, and that's it.
Honest Pros and Cons
Pros
Zero changes to the React app. No getServerSideProps, no component refactoring. The server lives separately, the SPA lives separately. Minimal overhead. The proxy only kicks in for bots. For regular users, it’s just fast static file serving. Graceful degradation out of the box. If the API is unavailable, users get the normal SPA — not a 500, not a white screen. Just the app without a social preview card. Fixes Safari ITP as a side effect. Analytics as a bonus — wasn’t planned, but here we are. Easy to maintain. TypeScript, minimal dependencies, clear structure. Adding a new page type takes about 20 minutes.
Cons
-
- User-Agent bot detection is inherently unreliable. User-Agents can be spoofed. This isn’t a concern with real social media bots — they’re honest. But anyone who wants to bypass the logic can. For our use case, that’s an acceptable tradeoff.
-
- readFileSync on every request. The file gets read from disk on every bot request. The right approach is to read it once on startup and keep it in memory. The current load doesn't force the issue, but it's technical debt.
-
- No API response caching. Every bot request is a round-trip to the internal API. Under heavy bot load — say, a single link getting shared widely — this could become a problem. Redis with a TTL would solve it.
-
- ETag via Date.now() isn't a real ETag. In the current implementation, the ETag changes with every response, which makes it effectively useless. A proper ETag should be derived from the content. Again, not critical at current scale, but it's on the list.
-
- This isn’t a replacement for full SEO. This approach solves the link preview problem. If you need Googlebot to index your actual text content, you need real SSR or static generation.
Conclusion
We had a choice: migrate to Next.js, or write a thin proxy. Migration means months of work, regression risk, and everything that comes with it. The proxy took a few days and solved the problem precisely.
Sometimes the right engineering call isn’t an architectural overhaul. Sometimes it’s a small server that does exactly one thing: inject five meta tags into HTML for bots.
The server started with one job — OG previews. Along the way it picked up a second — Safari ITP. The codebase stayed untouched.
If you’re in a similar spot, this approach might be worth considering before reaching for “just rewrite it in Next.”