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.tsunder Node 22.18 with the WebRequestandResponseobjects 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:imagepoints 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_ORIGINcan 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
GETfunction inapp/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_ttlreuses 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.