Netlify Function for Screenshot OG Images

Serve a 1200x630 og:image for every page of a Netlify site from one function, cache it in Netlify's durable CDN and warm it with a build plugin after deploys.

By Simon Chiu · Updated

A static site on Netlify has no server to draw a social card, so most teams either design one image for the whole site or add a template step to the build. This page shows a third way. One Netlify Function answers /og/<page>.png with a 1200×630 screenshot of that page, rendered by RenderScreenshot from a signed URL. Netlify's durable cache keeps the PNG at the edge, and a small build plugin requests the cards for your sitemap right after each production deploy, so crawlers do not wait for a render. It is part of the integrations hub; for the product itself see the OG image API.

Last tested: 2026-10-18 with Node 22.18. We called the function's default export with Web Request objects, as Netlify Functions do, against a local stub that checks signatures with the rules in the signed URL docs. The tests covered a page, the home page, a render timeout (502 with no-store), a path with illegal characters (400) and a wrong signing key (invalid_signature). We ran the plugin's onSuccess against a sitemap with three URLs. We did not deploy to Netlify for this page, so there are no Netlify UI screenshots and the cache headers were not observed on Netlify's CDN.

What you can build

  • A card for every page of a static site. Hugo, Astro, Eleventy or plain HTML: add <meta property="og:image" content="https://www.example.com/og/blog/hello-world.png"> to your layout, and the image exists the first time anyone asks for it.
  • Cards for a separate card template. If your site builds a card page per post (for example /cards/blog/hello-world/), set CARD_PREFIX in the function and it captures that page instead of the post itself.
  • Deploy-proof previews. The build plugin requests up to 50 cards after a deploy, so shares in the first minutes after a release do not hit an empty cache.

How the pieces fit

sequenceDiagram
  participant C as Crawler
  participant N as Netlify CDN (durable)
  participant F as og function
  participant R as RenderScreenshot
  C->>N: GET /og/blog/hello-world.png
  alt cached since the last deploy
    N-->>C: PNG
  else miss
    N->>F: run function
    F->>R: signed GET /v1/screenshot (cache_available=true)
    R-->>F: PNG (new render, or the stored one for 0 credits)
    F-->>N: PNG + Netlify-CDN-Cache-Control: durable
    N-->>C: PNG
  end

The browser never sees an API key. The function signs a short-lived URL with your secret key, and the URL expires 10 minutes after it is made, because only the function uses it.

Prerequisites

  • A Netlify site with Functions enabled (the default) and Node 22 as the functions runtime.
  • A RenderScreenshot public key ID (rs_pub_) and secret key (rs_secret_) from the dashboard. A live rs_live_ key cannot sign URLs. Create a free account if you do not have one.
  • A sitemap.xml in the publish directory, if you want the warm-up plugin.

Step 1: the function

Save as netlify/functions/og.mjs. It has no dependencies.

// netlify/functions/og.mjs: serves /og/<page-path> as a 1200x630 PNG of that page.
// Renders only on a CDN miss; Netlify's durable cache serves everything else.
import crypto from 'node:crypto';

const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const SITE = process.env.SITE_ORIGIN;          // https://www.example.com (no trailing slash)
const PREFIX = process.env.CARD_PREFIX ?? '';   // optional, for example /cards
const KEY_ID = process.env.RS_PUBLIC_KEY_ID;    // rs_pub_...
const SECRET = process.env.RS_SECRET_KEY;      // rs_secret_... (never leaves the function)

// CGI.escape rules: keep A-Z a-z 0-9 - . _ ~, space -> '+', everything else %XX.
const esc = (v) =>
  encodeURIComponent(v)
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
    .replace(/%20/g, '+');

function signedUrl(params) {
  const query = Object.entries(params).map(([k, v]) => `${esc(k)}=${esc(String(v))}`).sort().join('&');
  const signature = crypto.createHmac('sha256', SECRET).update(query).digest('hex');
  return `${API}/v1/screenshot?${query}&signature=${signature}`;
}

export default async (req) => {
  // /og/blog/hello-world.png -> /blog/hello-world/ ; /og/index.png -> /
  let path = new URL(req.url).pathname.replace(/^\/og/, '').replace(/\.png$/, '');
  if (!/^\/[\w\-/]*$/.test(path)) return new Response('Bad path', { status: 400 });
  path = path === '/index' ? '/' : path.replace(/\/?$/, '/');

  const target = signedUrl({
    url: SITE + PREFIX + path,
    preset: 'og_card',
    cache_available: 'true',               // a re-render after a deploy reuses the stored card: 0 credits
    cache_ttl: process.env.CARD_CACHE_TTL ?? '86400',
    key_id: KEY_ID,
    expires: Math.floor(Date.now() / 1000) + 600,
  });

  let res;
  try {
    res = await fetch(target, { signal: AbortSignal.timeout(25_000) });
  } catch (e) {
    return new Response(`Upstream unreachable: ${e.name}`, { status: 502, headers: { 'Cache-Control': 'no-store' } });
  }
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    console.error('render failed', res.status, body.error?.code, path);
    // Do not cache failures, so the next crawler request tries again.
    return new Response('Card unavailable', { status: res.status === 429 ? 503 : 502, headers: { 'Cache-Control': 'no-store' } });
  }
  return new Response(res.body, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=3600',
      // Shared across all Netlify edge nodes until the next deploy.
      'Netlify-CDN-Cache-Control': 'public, durable, s-maxage=31536000',
    },
  });
};

export const config = { path: '/og/*' };

Set SITE_ORIGIN, RS_PUBLIC_KEY_ID and RS_SECRET_KEY under Site configuration → Environment variables, scoped to Functions. CARD_CACHE_TTL and CARD_PREFIX are optional.

Three choices in this file matter:

  • The target is always your own origin. The function builds the URL from SITE_ORIGIN and a path that may contain only letters, digits, -, _ and /. Nobody can use your function to screenshot other sites on your credits.
  • Two cache headers. Cache-Control tells browsers to keep the image for an hour. Netlify-CDN-Cache-Control with durable tells Netlify to store the response in its shared cache, so a crawler in another region does not start a second render.
  • Failures are no-store. A 502 is never cached, so one slow page does not leave a broken card at the edge until the next deploy.

Step 2: the warm-up build plugin

Create plugins/og-warm/manifest.yml with the single line name: og-warm, and plugins/og-warm/package.json with { "type": "module" } so that Node loads the plugin as an ES module. Then add plugins/og-warm/index.js:

// plugins/og-warm/index.js: after a production deploy, request /og/... for the pages
// in sitemap.xml so the first crawler gets a cached card. Never fails the build.
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

export const onSuccess = async ({ constants, utils }) => {
  if (process.env.CONTEXT !== 'production') return;
  const site = process.env.URL;
  const limit = Number(process.env.OG_WARM_LIMIT ?? 50);
  let xml;
  try {
    xml = await readFile(join(constants.PUBLISH_DIR, 'sitemap.xml'), 'utf8');
  } catch {
    utils.status.show({ title: 'og-warm', summary: 'No sitemap.xml in the publish directory; nothing warmed.' });
    return;
  }
  const paths = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)]
    .map((m) => new URL(m[1]).pathname)
    .slice(0, limit);

  let ok = 0;
  const failed = [];
  // Two at a time keeps the warm-up gentle; a 429 is reported, never retried here.
  for (let i = 0; i < paths.length; i += 2) {
    await Promise.all(paths.slice(i, i + 2).map(async (p) => {
      const card = p === '/' ? '/og/index.png' : `/og${p.replace(/\/$/, '')}.png`;
      try {
        const res = await fetch(site + card, { signal: AbortSignal.timeout(30_000) });
        await res.arrayBuffer();
        res.ok ? ok++ : failed.push(`${card} (${res.status})`);
      } catch (e) {
        failed.push(`${card} (${e.name})`);
      }
    }));
  }
  utils.status.show({
    title: 'og-warm',
    summary: `Warmed ${ok} of ${paths.length} cards.`,
    text: failed.length ? `Failed: ${failed.join(', ')}` : undefined,
  });
};

Register it in netlify.toml:

[[plugins]]
package = "/plugins/og-warm"

The plugin runs in onSuccess, after the build has succeeded, and it only reports through utils.status.show. A card that fails to warm never fails your deploy. The deploy summary shows a line such as "Warmed N of M cards" and lists the paths that failed.

Parameter mapping

Signed URL parameter Value Why
url SITE_ORIGIN + CARD_PREFIX + page path The page to capture, always on your own site
preset og_card 1200×630 PNG; see presets
cache_available true Reuses an identical render from your account for 0 credits
cache_ttl 86400 (default) How long the API keeps that render; 3600 to 2592000 seconds
key_id, expires, signature From the function Signed URLs need all three; expires is at most 30 days ahead

cache_ttl is the main trade-off. While the API's stored render is valid, a redeploy reuses it, so a changed page title shows on the card only after the TTL ends. Keep the default of one day for blogs, or set a shorter value if titles change often. The caching docs explain the lookup.

Errors and retries

The function does not retry. A retry inside a function adds to the time a crawler waits, and the crawler will ask again anyway. Because failures are not cached, the next request is the retry.

What you see Cause Fix
502 and invalid_signature in function logs RS_SECRET_KEY is not the secret key that belongs to RS_PUBLIC_KEY_ID Copy both from the same dashboard row
502 and timeout The page did not load within the render timeout Make the page lighter, or capture a simpler card template with CARD_PREFIX
503 too_many_requests: the account's per-minute limit, shared by all keys Lower OG_WARM_LIMIT, or spread warm-ups across deploys
400 Bad path The path has characters outside the allowed set Use plain slugs in card URLs

See error codes for every code.

Limits and costs

  • Credits. A fresh render costs one credit. Re-renders after a deploy cost nothing while the API's stored render is within cache_ttl. Failed renders cost nothing.
  • Function time. Netlify limits how long a synchronous function may run. The function aborts its upstream call at 25 seconds; check Netlify's current function limits for your plan and change the timeout to match.
  • Function invocations. Each CDN miss is one invocation. With the durable cache, that is about one per card per deploy.
  • Rate limits. Warm-ups count toward your account's per-minute limit. On the Free plan that is 10 requests a minute, so keep OG_WARM_LIMIT low there.

Troubleshooting

  • The card shows a cookie banner. Add block_cookies: 'true' to the signed parameters. It is a GET parameter, so it can be signed.
  • The plugin warms nothing. It only runs when CONTEXT is production. Deploy previews are skipped on purpose.
  • Shares show an old card. Netlify cleared its cache, but the API reused its stored render within cache_ttl. Wait for the TTL or lower it.

For the same idea on other hosts, compare the Vercel function and the Cloudflare Pages version.

Frequently asked questions

Can I run Puppeteer inside a Netlify Function instead?
It is possible with a slim Chromium build, but you then own the bundle size, the cold start, fonts and the function time limit. The function on this page only makes one HTTP request and streams the PNG back.
Why does every deploy render the cards again?
Netlify clears cached responses when a new production deploy goes live, so the next request for each card runs the function again. The function sends cache_available=true, so the API returns the card it already rendered for 0 credits instead of rendering it again.
Does a crawler wait for the render?
Only on the first request after a deploy, and only if the build plugin did not already warm that card. Warmed cards come straight from Netlify's CDN.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.