TUTORIAL · MAR 2026

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.

OG image preview for this article generated by OGPeek

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:

  1. 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.
  2. 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.
  3. 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:

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:

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:

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.