Vercel Screenshot Function for OG Images

A Vercel Function that serves screenshot-based OG images from your own domain: signed API URL, CDN cache headers, a duration budget and no Chromium bundle.

By Simon Chiu · Updated

There are three ways to make a screenshot-based Open Graph image on Vercel: render JSX with @vercel/og, run Chromium inside a Vercel Function, or let a small function fetch a signed RenderScreenshot URL and hand the PNG to Vercel's CDN. This page builds the third one: a single api/og.ts file that serves https://your-site/api/og?path=/blog/hello-world from your own domain. It runs only when the CDN does not already hold the card, and it never starts a browser. It is part of the integrations hub.

Last tested: 2026-10-18. We ran api/og.ts under Node 22.18 with the Web Request and Response objects that Vercel's Node.js runtime uses, against a local stub that checks signatures exactly as the signed URL docs describe. The test covered a good card, a tampered URL (invalid_signature), a render failure (422) and an unreachable API. The signer reproduces the documented reference signature. We did not deploy the file to Vercel for this page, so there are no dashboard screenshots.

What you can build

  • Per-post cards for a static or ISR blog. The page's og:image points at /api/og?path=/blog/<slug>. The first crawler fetch renders the card; later fetches come from the CDN.
  • Cards for a site you do not build on Vercel. SITE_ORIGIN can be any origin you own, so a Vercel project can serve cards for a docs site hosted elsewhere.
  • One image endpoint with your domain on it. Some teams want every asset under their own host for CSP rules or brand reasons. The proxy gives you that without storing files.

Which approach fits

@vercel/og Chromium in a function This function
What renders JSX through Satori, a CSS subset Your real page Your real page, in our browser
Bundle Small A Chromium build that must fit Vercel's function size limit No extra dependency
Fonts and emoji Load each font file yourself Install fonts in the bundle Rendered with our fonts
Duration on a miss Short Browser start plus page load One HTTP request
Cost per card Function time only Function time and memory One credit per fresh render

If your cards are simple flexbox layouts, @vercel/og is free and fast; the Vercel OG comparison covers that trade-off in depth. Choose the screenshot route when the card must match real CSS, such as grid, web fonts or a component you already have. If you would rather put the signed URL directly in the tag with no function at all, the OG image production guide shows that with Next.js.

Prerequisites

  • A Vercel project. The file works in any framework; in Next.js, put the same GET function in app/api/og/route.ts.
  • A signing key pair: a public key ID (rs_pub_...) and a secret key (rs_secret_...). In the dashboard, open API Keys, choose Create Key, then Signing key pair. Sign up first if you have no account.
  • Your card route, for example /blog/<slug>?card=1, that lays out a 1200×630 page. Without one, the API captures the top of the normal page.

Step 1: add the function

// api/og.ts: Vercel Function (Node.js runtime).
// GET /api/og?path=/blog/hello-world -> a 1200x630 PNG of that page, served from your domain.
import { createHmac } from 'node:crypto';

const API = process.env.RS_API_BASE ?? 'https://api.renderscreenshot.com';
const SITE = process.env.SITE_ORIGIN ?? 'https://example.com'; // the only site we capture
const ALLOWED_PATH = /^\/(?:blog|docs)\/[a-z0-9-]+\/?$/; // routes that get a card

// The API's signing encoding: space -> "+", only A-Z a-z 0-9 - _ . ~ left as is.
function encode(value: string): string {
  return encodeURIComponent(value)
    .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase())
    .replace(/%20/g, '+');
}

export function signedScreenshotUrl(params: Record<string, string>, ttlSeconds = 300): string {
  const keyId = process.env.RS_PUBLIC_KEY_ID; // rs_pub_...
  const secret = process.env.RS_SECRET_KEY; // rs_secret_..., stays in this function
  if (!keyId || !secret) throw new Error('Set RS_PUBLIC_KEY_ID and RS_SECRET_KEY');

  const expires = String(Math.floor(Date.now() / 1000) + ttlSeconds);
  const query = Object.entries({ ...params, key_id: keyId, expires })
    .map(([k, v]) => `${encode(k)}=${encode(v)}`)
    .sort()
    .join('&');
  const signature = createHmac('sha256', secret).update(query).digest('hex');
  return `${API}/v1/screenshot?${query}&signature=${signature}`;
}

export async function GET(request: Request): Promise<Response> {
  const path = new URL(request.url).searchParams.get('path') ?? '';
  if (!ALLOWED_PATH.test(path)) {
    // Only your own card routes: strangers cannot spend your credits on other sites.
    return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } });
  }

  const signed = signedScreenshotUrl({
    url: new URL(path, SITE).toString(),
    preset: 'og_card', // 1200x630 PNG
    cache_available: 'true', // a later CDN miss reuses the render: 0 credits
    cache_ttl: '604800', // 7 days
  });

  // Give up before the function's maxDuration does, so we can answer cleanly.
  const upstream = await fetch(signed, { signal: AbortSignal.timeout(55_000) }).catch((e: Error) => e);
  if (upstream instanceof Error || !upstream.ok || !upstream.body) {
    const detail = upstream instanceof Error ? upstream.message : `${upstream.status} ${await upstream.text()}`;
    console.error('og render failed', path, detail);
    // Never let the CDN keep an error: the next crawler fetch tries again.
    return new Response('Card unavailable', { status: 502, headers: { 'Cache-Control': 'no-store' } });
  }

  return new Response(upstream.body, {
    headers: {
      'Content-Type': upstream.headers.get('content-type') ?? 'image/png',
      // Browsers keep it 1 hour; Vercel's CDN keeps it 7 days and serves it without running this function.
      'Cache-Control': 'public, max-age=3600, s-maxage=604800, stale-while-revalidate=86400',
    },
  });
}

Point url at your card route by changing new URL(path, SITE) to add ?card=1, and widen ALLOWED_PATH to match your routes.

Because the signed URL never leaves the function, it can expire after five minutes. Long expiries matter only when a signed URL sits in public HTML, and then the 30-day maximum applies.

Step 2: set the duration and the keys

Add a vercel.json next to package.json. The value covers the API's longest page wait (60 seconds) plus the function's own work:

{
  "functions": {
    "api/og.ts": { "maxDuration": 60 }
  }
}

In Next.js, write export const maxDuration = 60; in the route file instead. The highest value you can set depends on your Vercel plan.

Then add the variables for production and redeploy:

vercel env add RS_PUBLIC_KEY_ID production
vercel env add RS_SECRET_KEY production
vercel env add SITE_ORIGIN production
vercel deploy --prod

Step 3: point the tag at the function

<meta property="og:image" content="https://your-site.com/api/og?path=/blog/hello-world">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">

Open the URL in a browser once. The first request renders the card. Request it again and the response should come from Vercel's CDN (look for x-vercel-cache: HIT).

Parameter mapping

Function input Sent to the API Why
path query url = SITE_ORIGIN + path Only paths that match ALLOWED_PATH are signed
fixed preset=og_card 1200×630 PNG, see presets
fixed cache_available=true A second render of the same card costs 0 credits
fixed cache_ttl=604800 How old a reusable render may be (3600 to 2592000)
env key_id, expires, signature Signed per request; no API key in any URL

Errors and retries on Vercel

The function does not retry. A crawler that gets a 502 will fetch again later, and the no-store header stops Vercel's CDN from keeping the error. Inside the function, a retry would spend your duration budget on a page that already failed once.

Read the reason in your Vercel runtime logs. The line og render failed carries the HTTP status and the API's JSON error, including request_id. A 401 there is a signing problem: check that the key pair belongs to one account and that nothing edits the URL after signing (see invalid_signature). A 422 with timeout is the target page; the error code list says which codes are worth another try.

Limits and costs

  • Credits. Each CDN miss that needs a fresh render uses one credit. A miss for a card already rendered inside cache_ttl reuses it for 0 credits, for example after a redeploy.
  • Rate limit. Per account and shared by all keys and signed URLs. A launch that shares many new posts at once sends many misses; see rate limits.
  • Vercel. The function runs only on misses, and it spends that time waiting on the network. How Vercel bills that time depends on your plan; check Vercel's Functions pricing page.
  • Abuse. The path check is the main defense. Without it, anyone could request any URL and spend your credits.

Troubleshooting

Symptom Cause Fix
Every request runs the function A Cache-Control from middleware overrides ours, or the response is an error Check the final headers with curl -I
404 for a real post Path does not match ALLOWED_PATH Widen the pattern, keep it specific
502 and invalid_signature in logs Secret from another account, or a stray space in the env value Re-add both variables, redeploy
Card shows the site header, not a card url points at the article itself Add a card route and capture that
Old card after an edit CDN and API cache both hold it Add a version, such as the post's update time, to the captured URL

The same pattern on Cloudflare runs as middleware in a Pages Function. On Netlify, use a Netlify Function. For background captures rather than request-time cards, see AWS Lambda.

Frequently asked questions

Can I run Puppeteer and Chromium inside a Vercel Function?
Yes, with a slim Chromium build such as @sparticuz/chromium and puppeteer-core. You then own the bundle size, the cold start, the fonts and the memory setting. A function that calls a screenshot API only waits on one HTTP request.
How long does the function run?
Only on a CDN miss, and then for as long as the render takes. The API's own page timeout is at most 60 seconds, so the function aborts its fetch at 55 seconds and sets maxDuration to 60. Cached cards are served by Vercel's CDN without running the function.
Why not redirect to the signed URL instead of proxying it?
A redirect adds a hop that every crawler must follow, and some link unfurlers handle it badly. If you do not want the bytes on your domain, put the signed URL straight into the og:image tag instead.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.