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:imagetag 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 withrs_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_IDandRS_SECRET_KEY. For local runs, put them in.dev.vars.
How the request flows
- A crawler requests
/blog/hello. The middleware runs, gets the static HTML fromcontext.next()and collects the<title>text. - At
</head>it signsGET /v1/screenshot?url=https://your-site/card?title=...&preset=og_cardand writesog:imageandtwitter:image. - The crawler fetches that URL. RenderScreenshot loads your
/cardpage 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 & arrives as "&". function decodeEntities(text) { return text .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') .replace(/'/g, "'").replace(/&/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('&', '&'); 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 dayswould 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=trueis 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 untilcache_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
&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
&. Your template engine escaped the title twice. ThedecodeEntitieshelper 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 & 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.