Dynamic OG Images in Express.js with the OGPeek API
Express does not manage your HTML <head> for you. There is no built-in convention for og:image tags, no metadata plugin, no middleware that magically injects social preview cards. You do it yourself—in your templates, in your route handlers, or in custom middleware. This guide shows you exactly how to wire up dynamic OG images in Express using EJS templates, middleware patterns, caching, and the OGPeek API, so every route in your app gets a unique social card without Puppeteer, Canvas, or any image processing on your server.
Why Most Express Apps Have Broken Social Previews
Express is unopinionated by design. It gives you routing and middleware—everything else is your responsibility. That includes the og:image meta tag that determines what people see when your URL gets pasted into Slack, Twitter, LinkedIn, or iMessage.
The default state of most Express apps is one of three things: no OG image at all (social platforms show a gray placeholder or auto-crop your page), a single static image set in the layout template (every shared link looks identical), or a broken URL from an old integration that nobody maintained.
Developers avoid fixing this because generating images in Node.js has historically required painful options:
- Puppeteer / Playwright: Launch a headless browser, render HTML to PNG. Works beautifully, but adds 150–300 MB to your deployment, takes 500ms–2s per render, and is notoriously difficult to run on serverless platforms.
- node-canvas (Cairo bindings): Draw images with a Canvas API. Requires native system libraries (Cairo, Pango, libjpeg), which break frequently across OS versions and CI environments.
- Sharp / Jimp: Image manipulation libraries. They can composite text onto images, but you are writing low-level drawing code, managing fonts, and handling text wrapping yourself.
A URL-based API eliminates all three. You construct a URL string, put it in a meta tag, and the image is generated and cached externally. Your Express server does zero image work.
The OGPeek URL Structure
OGPeek generates 1200×630 PNG images from query parameters. A request looks like this:
https://todd-agent-prod.web.app/api/v1/og
?title=My+Express+Blog+Post
&subtitle=Node.js+%C2%B7+5+min+read
&template=gradient
&theme=dark
&brandColor=%23F59E0B
The key parameters:
title— Main headline rendered large. Keep it under 60 characters for clean wrapping.subtitle— Secondary text below the title. Category, author, date, or tagline.template— Visual layout:gradient,minimal,split.theme— Color theme:dark,midnight,dawn,slate.brandColor— Accent color as URL-encoded hex.%23F59E0Bis amber.
Because it is a standard GET request that returns an image, any social crawler can fetch it directly. No auth, no cookies, no JavaScript execution required.
Project Setup and Dependencies
Start with a standard Express project. You need very few dependencies—the OGPeek integration is just URL construction, which Node.js handles natively:
mkdir express-og-demo && cd express-og-demo
npm init -y
npm install express ejs
For the optional caching and HTTP request features covered later in this guide:
# Optional: caching OG URLs locally
npm install node-cache
# Optional: if you need to pre-fetch or validate OG images
npm install axios
# Optional: security headers (pairs well with OG middleware)
npm install helmet
You do not need node-fetch for basic OG image integration. Since OGPeek URLs are rendered by social crawlers (not your server), you are building URL strings, not making HTTP requests. The axios or node-fetch packages only come into play if you want to pre-warm or validate images from a CLI script.
Building OG Image URLs in Node.js
Use the built-in URLSearchParams class to safely encode query parameters. Never use template literal concatenation for URLs—a title like "Express & Koa: A Comparison" will break your meta tag if the ampersand is not encoded:
// lib/ogImage.js
const OGPEEK_BASE = 'https://todd-agent-prod.web.app/api/v1/og';
function buildOgImageUrl(title, subtitle = null, options = {}) {
const {
template = 'gradient',
theme = 'dark',
brandColor = '#F59E0B',
} = options;
const params = new URLSearchParams({
title: title.slice(0, 60),
template,
theme,
brandColor,
});
if (subtitle) {
params.set('subtitle', subtitle.slice(0, 80));
}
return `${OGPEEK_BASE}?${params.toString()}`;
}
module.exports = { buildOgImageUrl };
URLSearchParams handles encoding of &, #, +, spaces, and Unicode characters correctly. It is built into Node.js since v10—no dependencies required.
Why not template literals? `...?title=${title}` will silently produce invalid URLs when the title contains &, =, +, #, or non-ASCII characters. URLSearchParams handles all of these and costs nothing.
Express Middleware for Automatic OG Tags
The cleanest Express pattern is middleware that injects OG image data into res.locals, making it available to every template without passing it explicitly from each route handler:
// middleware/ogImage.js
const { buildOgImageUrl } = require('../lib/ogImage');
function ogImageMiddleware(defaultTitle = 'My App', defaultSubtitle = null) {
return (req, res, next) => {
// Set a default OG image that routes can override
res.locals.ogImage = buildOgImageUrl(defaultTitle, defaultSubtitle);
res.locals.ogUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
// Helper function available in all templates
res.locals.buildOgImageUrl = buildOgImageUrl;
next();
};
}
module.exports = { ogImageMiddleware };
Registering the middleware
// app.js
const express = require('express');
const { ogImageMiddleware } = require('./middleware/ogImage');
const app = express();
app.set('view engine', 'ejs');
app.set('views', './views');
// Apply OG middleware globally — every route gets a default OG image
app.use(ogImageMiddleware('My Express App', 'Build something people want.'));
app.get('/', (req, res) => {
res.render('index', {
pageTitle: 'My Express App',
pageDescription: 'Build something people want.',
// ogImage is already set by middleware — no need to pass it
});
});
app.get('/blog/:slug', async (req, res) => {
const post = await getPost(req.params.slug);
// Override the default OG image with a post-specific one
res.locals.ogImage = buildOgImageUrl(
post.title,
`${post.category} · ${post.readTime} min read`
);
res.render('blog-post', {
pageTitle: post.title,
pageDescription: post.excerpt,
ogType: 'article',
post,
});
});
app.listen(3000);
Every route handler now has a default OG image. Routes that need a specific card override res.locals.ogImage. Routes that do not bother get a branded fallback. No more blank social previews on any page.
Dynamic OG Images with EJS Templates
Your EJS layout template renders the OG meta tags from the variables set by your middleware and route handlers:
The layout template
<!-- views/layout.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= pageTitle %> — My App</title>
<meta name="description" content="<%= pageDescription %>">
<!-- Open Graph -->
<meta property="og:title" content="<%= pageTitle %>">
<meta property="og:description" content="<%= pageDescription %>">
<meta property="og:type" content="<%= typeof ogType !== 'undefined' ? ogType : 'website' %>">
<meta property="og:url" content="<%= ogUrl %>">
<meta property="og:image" content="<%= ogImage %>">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<%= pageTitle %>">
<meta name="twitter:description" content="<%= pageDescription %>">
<meta name="twitter:image" content="<%= ogImage %>">
</head>
<body>
<%- body %>
</body>
</html>
Using Pug instead of EJS
If you use Pug (formerly Jade), the same pattern works with Pug syntax. The variable names from res.locals are available directly:
//- views/layout.pug
doctype html
html(lang="en")
head
meta(charset="UTF-8")
meta(name="viewport" content="width=device-width, initial-scale=1.0")
title #{pageTitle} — My App
meta(name="description" content=pageDescription)
meta(property="og:title" content=pageTitle)
meta(property="og:description" content=pageDescription)
meta(property="og:image" content=ogImage)
meta(property="og:image:width" content="1200")
meta(property="og:image:height" content="630")
meta(name="twitter:card" content="summary_large_image")
meta(name="twitter:image" content=ogImage)
body
block content
Caching OG Image URLs with node-cache
OGPeek URLs are deterministic: the same parameters always return the same image. That means you can cache the URL string itself to avoid redundant URL construction, and more importantly, to keep track of which URLs you have already "used" against your daily quota.
For single-server Express apps, node-cache is the simplest option:
// lib/ogImageCached.js
const NodeCache = require('node-cache');
const { buildOgImageUrl } = require('./ogImage');
// Cache for 24 hours, check for expired keys every 10 minutes
const cache = new NodeCache({ stdTTL: 86400, checkperiod: 600 });
function getCachedOgImageUrl(title, subtitle = null, options = {}) {
const cacheKey = `og:${title}:${subtitle || ''}:${JSON.stringify(options)}`;
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const url = buildOgImageUrl(title, subtitle, options);
cache.set(cacheKey, url);
return url;
}
module.exports = { getCachedOgImageUrl };
Scaling with Redis
If you run multiple Express instances behind a load balancer, use Redis so all instances share the same cache. The ioredis package is the standard choice:
// lib/ogImageRedis.js
const Redis = require('ioredis');
const { buildOgImageUrl } = require('./ogImage');
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
async function getCachedOgImageUrl(title, subtitle = null, options = {}) {
const cacheKey = `og:${title}:${subtitle || ''}`;
const cached = await redis.get(cacheKey);
if (cached) {
return cached;
}
const url = buildOgImageUrl(title, subtitle, options);
await redis.setex(cacheKey, 86400, url); // TTL: 24 hours
return url;
}
module.exports = { getCachedOgImageUrl };
Why cache a URL string? The URL construction itself is cheap. The real value of caching is tracking which images have been "requested" by social crawlers, deduplicating across routes that share similar titles, and keeping a local record for debugging. It also makes the Redis-based approach ready for rate-limit tracking if you later add a counter alongside the URL.
Rate Limiting Awareness
OGPeek's free tier allows 50 image generations per day. Because OGPeek caches images on its CDN, the same URL does not count against your quota on repeat fetches. But each unique parameter combination counts as one generation the first time a crawler requests it.
For a blog with 20 posts, that is 20 unique URLs—well within the free tier. For larger apps with user-generated content or dynamic routes, consider these strategies:
- Normalize titles: Trim to 60 characters and lowercase before building URLs, so "My Post" and "my post" do not generate two separate images.
- Use a fixed subtitle: Instead of a dynamic subtitle per page, use your site name. Fewer unique URLs = fewer generations.
- Pre-generate at deploy time: Run a batch script (covered below) that hits all your known URLs once. After that, social crawlers get CDN-cached responses.
- Upgrade when you need to: The Starter plan at $9/month gives you 10,000 images per month—enough for most SaaS apps.
Integration with helmet.js
Helmet.js is the standard Express middleware for HTTP security headers (Content-Security-Policy, X-Frame-Options, Strict-Transport-Security, etc.). It does not manage HTML meta tags—those are your template's job. But the two work together cleanly in your middleware stack:
// app.js
const express = require('express');
const helmet = require('helmet');
const { ogImageMiddleware } = require('./middleware/ogImage');
const app = express();
// Helmet handles HTTP security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", "https://todd-agent-prod.web.app"],
// Allow OGPeek images to load
},
},
}));
// OG middleware handles HTML meta tag data
app.use(ogImageMiddleware('My App', 'The tagline.'));
The important detail is the imgSrc directive in the Content Security Policy. If you use helmet's CSP and your pages display the OG image as a visible <img> tag (for preview purposes, debugging, or a social share widget), you need to whitelist the OGPeek domain. Social crawlers (Twitter, Facebook, Slack) do not respect CSP headers, so the og:image meta tag works regardless—but your own browser will block the image if CSP is too restrictive.
Batch Pre-Generation with a CLI Script
For sites with a known set of pages (blog posts, documentation, product pages), pre-generating OG images at deploy time ensures social crawlers always get a fast CDN-cached response. Here is a CLI script using axios:
#!/usr/bin/env node
// scripts/pre-generate-og.js
const axios = require('axios');
const { buildOgImageUrl } = require('../lib/ogImage');
// Define all pages that need OG images
const pages = [
{ title: 'My Express App', subtitle: 'Build something people want.' },
{ title: 'Getting Started Guide', subtitle: 'Documentation' },
{ title: 'Pricing', subtitle: 'Plans starting at $0/month' },
// Add your blog posts, product pages, etc.
];
async function preGenerate() {
console.log(`Pre-generating ${pages.length} OG images...\n`);
for (const page of pages) {
const url = buildOgImageUrl(page.title, page.subtitle);
try {
const res = await axios.head(url, { timeout: 10000 });
console.log(` ✓ ${page.title} (${res.status})`);
} catch (err) {
console.error(` ✗ ${page.title}: ${err.message}`);
}
// Small delay to be respectful of rate limits
await new Promise(r => setTimeout(r, 200));
}
console.log('\nDone. All images are now cached on the CDN.');
}
preGenerate();
Add it to your package.json scripts and run it as part of your deploy pipeline:
{
"scripts": {
"build": "...",
"predeploy": "node scripts/pre-generate-og.js",
"deploy": "firebase deploy --only hosting"
}
}
A HEAD request is enough to trigger OGPeek to generate and cache the image. You do not need to download the full PNG. For 50 pages, the script runs in about 15 seconds.
Tip: If your blog posts come from a database or CMS, modify the script to fetch all published posts and generate their OG URLs dynamically. That way new posts get pre-warmed images automatically on every deploy.
OGPeek Pricing
Choose the tier that matches your traffic. All tiers return 1200×630 PNG images via a simple GET URL:
| Plan | Price | Images | Features |
|---|---|---|---|
| Free | $0 | 50 / day | All templates, all themes, no credit card |
| Starter | $9 / mo | 10,000 / mo | Custom branding, priority CDN, API key |
| Pro | $29 / mo | 50,000 / mo | Priority rendering, custom fonts, premium templates |
For most Express apps, the free tier covers development and early production. A blog publishing one post per day uses 1 image per day. An e-commerce site with 500 products uses 500 unique images once, then serves from cache. You only need a paid plan when you have thousands of unique pages generating new images regularly.
Complete Working Example
Here is the full Express app wired together—middleware, caching, EJS template, and route-specific OG images:
// app.js — complete example
const express = require('express');
const helmet = require('helmet');
const NodeCache = require('node-cache');
const app = express();
const cache = new NodeCache({ stdTTL: 86400 });
const OGPEEK_BASE = 'https://todd-agent-prod.web.app/api/v1/og';
function buildOgImageUrl(title, subtitle = null) {
const params = new URLSearchParams({
title: title.slice(0, 60),
template: 'gradient',
theme: 'dark',
brandColor: '#F59E0B',
});
if (subtitle) params.set('subtitle', subtitle.slice(0, 80));
return `${OGPEEK_BASE}?${params.toString()}`;
}
function getOgImage(title, subtitle = null) {
const key = `og:${title}:${subtitle || ''}`;
const hit = cache.get(key);
if (hit) return hit;
const url = buildOgImageUrl(title, subtitle);
cache.set(key, url);
return url;
}
// Middleware stack
app.set('view engine', 'ejs');
app.set('views', './views');
app.use(helmet());
app.use((req, res, next) => {
res.locals.ogImage = getOgImage('My Express App');
res.locals.ogUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
next();
});
// Routes
app.get('/', (req, res) => {
res.render('index', {
pageTitle: 'My Express App',
pageDescription: 'Build something people want.',
});
});
app.get('/blog/:slug', async (req, res) => {
const post = await getPost(req.params.slug);
res.locals.ogImage = getOgImage(post.title, post.category);
res.render('blog-post', {
pageTitle: post.title,
pageDescription: post.excerpt,
ogType: 'article',
post,
});
});
app.get('/products/:id', async (req, res) => {
const product = await getProduct(req.params.id);
res.locals.ogImage = getOgImage(
product.name,
`$${product.price} · ${product.category}`
);
res.render('product', {
pageTitle: product.name,
pageDescription: product.tagline,
product,
});
});
app.listen(3000, () => console.log('Running on http://localhost:3000'));
Testing Your OG Tags
Before deploying, verify the tags render correctly. A quick curl is faster than opening a browser and inspecting the source:
# Check that og:image appears in the rendered HTML
curl -s http://localhost:3000/blog/my-post | grep -o 'og:image.*content="[^"]*"'
# Or use Node.js for a quick validation script
node -e "
const http = require('http');
http.get('http://localhost:3000/blog/my-post', res => {
let html = '';
res.on('data', c => html += c);
res.on('end', () => {
const matches = html.match(/property=\"og:[^\"]+\"[^>]+content=\"([^\"]+)\"/g);
if (matches) matches.forEach(m => console.log(m));
else console.log('No OG tags found!');
});
});
"
For production verification, use the official validator tools:
- Twitter/X:
cards-dev.twitter.com/validator - Facebook/LinkedIn:
developers.facebook.com/tools/debug/ - Slack: Paste a URL in a DM to yourself. Slack renders cards within seconds.
- OpenGraph.xyz: Third-party validator that shows exactly what crawlers see.
Social platforms cache OG images aggressively—often for days. If you update OG tags and the old image persists, use the platform's debug tool to force a re-fetch. Appending &v=2 to the OGPeek URL also busts the cache on most platforms.
Add OG Images to Your Express App Today
Build and preview dynamic OG image URLs interactively. No account required—50 free images per day.
Try the playground →Wrapping Up
Express gives you the flexibility to inject OG images however you want—through middleware, route handlers, or template helpers. The setup is straightforward: a utility function that builds OGPeek URLs from URLSearchParams, middleware that sets a default in res.locals, route handlers that override it with page-specific data, and an EJS or Pug template that renders the meta tags.
The Puppeteer and node-canvas alternatives are technically capable but operationally expensive. They add hundreds of megabytes to your deployment, require native dependencies, and introduce latency on every image render. For the common case—branded title cards for blog posts, docs, products, and user profiles—a URL-based API is the right tool. You build a string, the CDN serves a PNG, and your server does nothing.
For more on OG images, see the complete OGPeek API reference, the image size and format guide, or the guide to dynamic OG images in Flask if you are also running a Python backend.