Cloudflare Pages OG Images with a Pages Function

Add a per-route og:image to every Cloudflare Pages site with one middleware file. Signed with Web Crypto, cache-friendly, tested in wrangler.

By Simon Chiu · Updated

You can give every page of a Cloudflare Pages site its own og:image without a build step and without touching your HTML files. One functions/_middleware.js file reads each page's <title>, builds a signed screenshot URL for a small card template, and injects the meta tags on the way out. The secret key stays in the Pages project. Crawlers fetch the image straight from RenderScreenshot.

This page is one recipe from the OG image API overview. If you run a standalone Worker instead of Pages, see Cloudflare Workers.

What you can build

  • Social cards for a static site (Hugo, Astro, Eleventy, plain HTML) on Pages, with no image files committed to the repository.
  • Cards for docs and changelogs, where new pages appear every week and nobody wants to design an image for each one.
  • A default card for pages without one. The middleware leaves any page that already has an og:image tag alone.

Prerequisites

  • A Cloudflare Pages project with a functions/ directory (Pages Functions).
  • A RenderScreenshot signing key pair: a public key ID that starts with rs_pub_ and a secret that starts with rs_secret_. See API keys. Create a free account first if you do not have one.
  • Two encrypted variables in Settings > Variables and Secrets: RS_PUBLIC_KEY_ID and RS_SECRET_KEY. For local runs, put them in .dev.vars.

How the request flows

  1. A crawler requests /blog/hello. The middleware runs, gets the static HTML from context.next() and collects the <title> text.
  2. At </head> it signs GET /v1/screenshot?url=https://your-site/card?title=...&preset=og_card and writes og:image and twitter:image.
  3. The crawler fetches that URL. RenderScreenshot loads your /card page at 1200 x 630 and returns a PNG.

Step 1: the card template

Save as public/card.html. Pages serves it at /card.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<title>Card</title>
<style>
  html, body { margin: 0; width: 1200px; height: 630px; }
  body { display: flex; flex-direction: column; justify-content: space-between;
         padding: 72px; box-sizing: border-box; background: #0f172a; color: #f8fafc;
         font: 600 64px/1.15 system-ui, sans-serif; }
  #path { font: 400 28px ui-monospace, monospace; color: #94a3b8; }
</style>
</head>
<body>
  <div id="title"></div>
  <div id="path"></div>
  <script>
    const q = new URLSearchParams(location.search);
    // textContent, not innerHTML: page titles are data and must not become markup.
    document.getElementById('title').textContent = q.get('title') ?? '';
    document.getElementById('path').textContent = location.hostname + (q.get('path') ?? '/');
  </script>
</body>
</html>

Point the middleware at /card, not /card.html. Pages answers /card.html with a 308 redirect to /card, and there is no reason to make the renderer follow it.

Step 2: the middleware

Save as functions/_middleware.js:

// functions/_middleware.js
// Adds a signed RenderScreenshot og:image to every HTML page served by Cloudflare Pages.
// Secrets: RS_PUBLIC_KEY_ID (rs_pub_...) and RS_SECRET_KEY (rs_secret_...).

const ENDPOINT = 'https://api.renderscreenshot.com/v1/screenshot';
const WEEK = 7 * 24 * 60 * 60;
const encoder = new TextEncoder();

// Encode like the API does: space -> "+", keep only A-Z a-z 0-9 - _ . ~
function encode(value) {
  return encodeURIComponent(String(value))
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
    .replace(/%20/g, '+');
}

async function signedUrl(params, keyId, secret) {
  // Expiry snaps to a weekly boundary 7 to 14 days ahead, so the URL stays
  // identical for a week and crawlers see a stable og:image.
  const now = Math.floor(Date.now() / 1000);
  const expires = (Math.floor(now / WEEK) + 2) * WEEK;
  const query = Object.entries({ ...params, key_id: keyId, expires })
    .map(([k, v]) => `${encode(k)}=${encode(v)}`)
    .sort()
    .join('&');
  const key = await crypto.subtle.importKey(
    'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
  );
  const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(query)));
  const hex = Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('');
  return `${ENDPOINT}?${query}&signature=${hex}`;
}

// HTMLRewriter hands over the raw text, so &amp; arrives as "&amp;".
function decodeEntities(text) {
  return text
    .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'").replace(/&amp;/g, '&');
}

export async function onRequest(context) {
  const response = await context.next();
  const type = response.headers.get('content-type') ?? '';
  const url = new URL(context.request.url);
  if (!type.includes('text/html') || url.pathname === '/card') {
    return response;
  }

  const { RS_PUBLIC_KEY_ID, RS_SECRET_KEY } = context.env;
  if (!RS_PUBLIC_KEY_ID || !RS_SECRET_KEY) return response; // never break the page

  let title = '';
  let hasOgImage = false;

  return new HTMLRewriter()
    .on('title', { text(chunk) { title += chunk.text; } })
    .on('meta[property="og:image"]', { element() { hasOgImage = true; } })
    .on('head', {
      element(head) {
        head.onEndTag(async (end) => {
          if (hasOgImage) return; // a page with its own og:image keeps it
          const card = new URL('/card', url.origin);
          card.searchParams.set('title', decodeEntities(title.trim()) || url.hostname);
          card.searchParams.set('path', url.pathname);
          const image = await signedUrl(
            { url: card.href, preset: 'og_card', cache_available: 'true', cache_ttl: '2592000' },
            RS_PUBLIC_KEY_ID,
            RS_SECRET_KEY,
          );
          const safe = image.replaceAll('&', '&amp;');
          end.before(
            `<meta property="og:image" content="${safe}">` +
              '<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="${safe}">`,
            { html: true },
          );
        });
      },
    })
    .transform(response);
}

Three choices in this file matter more than they look:

  • The expiry is bucketed. Signing with now + 7 days would produce a new URL on every request. The weekly boundary keeps the URL identical for a week, so platforms that cache previews by image URL keep their copy.
  • cache_available=true is signed in. Without it, every crawler fetch is a fresh render and uses a credit. With it, the API returns the stored render for 0 credits until cache_ttl (here the 30-day maximum) runs out. The expiry and key ID are not part of the cache key, so a new weekly URL still reuses the old render. See caching.
  • Ampersands are escaped as &amp; inside the attribute. Crawlers decode them back before they fetch.

Step 3: keep the Function off static assets

Each request that runs a Function is billed as a Workers request, while plain static asset requests are not (see Cloudflare's Pages Functions pricing page). Add public/_routes.json so images, CSS and the card itself skip the middleware:

{
  "version": 1,
  "include": ["/*"],
  "exclude": ["/assets/*", "/card"]
}

Test it

npx wrangler pages dev public
curl -s http://localhost:8788/blog/hello | grep -o 'og:image" content="[^"]*'

Then deploy and paste a page URL into a link preview checker. The first preview triggers the render. The OG images with signed URLs guide covers the platform side in more depth.

Parameter mapping

What you want Signed query parameter Note
1200 x 630 PNG preset=og_card See presets
Twitter's 2:1 size preset=twitter_card 1200 x 600, needs a second signed URL
Smaller file format=webp Some platforms ignore WebP; PNG is the safe default
Reuse renders cache_available=true, cache_ttl=2592000 TTL range 3600 to 2592000 seconds
Fresh card after a redesign change the card URL, for example add v=2 Any change gives a new cache key

Errors and retries

The middleware never calls the API, so a RenderScreenshot outage cannot break your HTML. Failures show up when a crawler fetches the image, as a missing preview. Open the og:image URL with curl -i to read the JSON error:

Status and code Cause Fix
401 invalid_signature The secret does not belong to the key_id, or a parameter was added after signing Check both variables; sign every parameter
401 expired_signature A crawler fetched a URL older than its expiry Expected for old shares; the next page view re-signs
422 screenshot_failed The renderer could not load /card, often on a preview deployment behind Cloudflare Access Test on the production domain, or make /card public

Crawlers do not retry on your schedule, so there is nothing to retry in code. Fix the cause and ask the platform to re-scrape the page.

Limits and costs

  • One credit per fresh render. Repeat fetches within the TTL cost 0. Plan allowances are on the pricing page.
  • Signed URLs may be valid for at most 30 days. The bucketed expiry here stays between 7 and 14 days.
  • Free plans allow 10 requests per minute per key. A viral share can exceed it before the cache is warm; see rate limits.

Troubleshooting

  • No tag appears. The variables are missing in this environment (preview and production have separate values), or the response is not text/html.
  • The card shows &amp;. Your template engine escaped the title twice. The decodeEntities helper handles one level.
  • The old image still shows. The platform cached it. Change the card URL and re-scrape.

Tested on 2026-10-15 with wrangler 4.145.0 (wrangler pages dev). We checked tag injection on three pages (a plain page, a title with &amp; and an accented character, and a page with its own og:image), then verified every signed URL with a copy of the API's own signature check and fetched it from a local API stub. Live crawler fetches were not part of this test.

Frequently asked questions

Can a Cloudflare Pages Function add og:image tags to static HTML?
Yes. A _middleware.js file runs before every matching request. It calls context.next() to get the static page and rewrites the head with HTMLRewriter, so the HTML files themselves do not change.
Does every crawler fetch of the og:image cost a credit?
Only the first one in each cache window. The signed URL includes cache_available=true and cache_ttl=2592000, so the API returns the stored render for 0 credits until the TTL ends or the card URL changes.
Why sign the URL instead of putting an API key in the meta tag?
A meta tag is public. A signed URL carries only the public key ID (rs_pub_), an expiry and an HMAC. Nobody can change the parameters, and the secret key stays in the Pages project settings.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.