Cloudflare Workers run on every Cloudflare edge node — 300+ cities globally. When someone in Tokyo shares a link to your site, the OG image gets generated by a Worker in Tokyo and cached locally. First request: 200ms (cold). Every subsequent request: 5ms (cache hit). For a content site that gets shared on Twitter and LinkedIn, this is the cheapest way to deliver beautiful per-page OG cards.
This tutorial shows you the SnapSharp + Workers integration end-to-end. You'll build a Worker that accepts URL parameters, calls SnapSharp, caches results in the Cache API, and returns images with proper headers. Deploy time: 30 seconds.
Prerequisites
- Node.js 20+ and npm/pnpm.
- A free Cloudflare account.
- The Wrangler CLI (
pnpm add -g wrangler). - A free SnapSharp API key from snapsharp.dev/sign-up.
- A custom domain (optional but recommended for production).
Step 1: scaffold the Worker
pnpm create cloudflare@latest snap-edge -- --type=hello-world --ts
cd snap-edgeThis generates a TypeScript Worker with a basic structure: src/index.ts, wrangler.toml, and tsconfig.json. Open wrangler.toml:
name = "snap-edge"
main = "src/index.ts"
compatibility_date = "2026-04-01"
compatibility_flags = ["nodejs_compat"]
[vars]
# Public vars only — secrets via wrangler secret put
[observability]
enabled = truecompatibility_flags = ["nodejs_compat"] enables a subset of Node APIs in case the SDK or your code depends on them. Workers run V8 directly — fetch, URL, crypto.subtle, etc. are global.
Step 2: store the API key as a secret
wrangler secret put SNAPSHARP_API_KEY
# paste sk_live_YOUR_API_KEY when promptedSecrets aren't visible in the dashboard or wrangler.toml. They're available inside the Worker via env.SNAPSHARP_API_KEY.
Step 3: a basic Worker handler
src/index.ts:
export interface Env {
SNAPSHARP_API_KEY: string;
}
const SNAPSHARP_BASE = 'https://api.snapsharp.dev/v1';
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/health') {
return new Response('ok');
}
if (url.pathname === '/og') {
return handleOg(request, env, ctx);
}
if (url.pathname === '/screenshot') {
return handleScreenshot(request, env, ctx);
}
return new Response('not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
async function handleOg(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const title = url.searchParams.get('title') || 'Untitled';
const author = url.searchParams.get('author') || '';
const date = url.searchParams.get('date') || '';
const tag = url.searchParams.get('tag') || '';
// Try cache first
const cacheKey = new Request(url.toString(), { method: 'GET' });
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) return cached;
// Cache miss — call SnapSharp
const apiResp = await fetch(`${SNAPSHARP_BASE}/og-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.SNAPSHARP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: 'blog-post',
variables: { title, author, date, tag },
width: 1200,
height: 630,
format: 'png',
}),
});
if (!apiResp.ok) {
return new Response('og generation failed', { status: 502 });
}
const image = await apiResp.arrayBuffer();
const response = new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, s-maxage=31536000',
},
});
// Store in cache asynchronously after response is sent
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
async function handleScreenshot(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const target = url.searchParams.get('url');
if (!target) return new Response('url required', { status: 400 });
const cacheKey = new Request(url.toString(), { method: 'GET' });
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) return cached;
const apiResp = await fetch(
`${SNAPSHARP_BASE}/screenshot?url=${encodeURIComponent(target)}&width=1280&height=720&format=png&block_ads=true`,
{
headers: { 'Authorization': `Bearer ${env.SNAPSHARP_API_KEY}` },
},
);
if (!apiResp.ok) return new Response('capture failed', { status: 502 });
const image = await apiResp.arrayBuffer();
const response = new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, s-maxage=31536000',
},
});
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}A few details unique to Workers:
caches.defaultis the per-data-center HTTP cache. Each edge city has its own. Cache hits never call SnapSharp.ctx.waitUntil()lets you keep doing work after returning the response. Caching writes happen async and don't block the response.s-maxage=31536000tells CDN intermediaries to cache for a year. The Worker's local cache obeysmax-agefor HTTP responses.
Step 4: deploying
wrangler deployThe deploy completes in seconds. You get a URL like https://snap-edge.your-subdomain.workers.dev. Test:
curl https://snap-edge.your-subdomain.workers.dev/og?title=Hello&author=Jane -o og.png
open og.png # macOS; xdg-open on LinuxYou should see a SnapSharp-generated OG card.
Step 5: custom domain routing
Add a custom domain so the URL is brand-aligned. In wrangler.toml:
[[routes]]
pattern = "og.yourdomain.com/*"
zone_name = "yourdomain.com"You'll need yourdomain.com in Cloudflare DNS. Then:
wrangler deployCloudflare wires up the route. Visit https://og.yourdomain.com/og?title=... and it serves through your domain.
Step 6: verifying signed URLs
To prevent abuse (anyone calling your endpoint to drain your SnapSharp quota), sign URLs with HMAC. Use a shared secret between your origin server (which generates URLs) and the Worker (which verifies them).
wrangler secret put SIGNING_SECRETIn the Worker:
async function verifySignature(url: URL, env: Env): Promise<boolean> {
const sig = url.searchParams.get('sig');
const exp = url.searchParams.get('exp');
if (!sig || !exp) return false;
if (Date.now() / 1000 > parseInt(exp, 10)) return false;
const params = new URLSearchParams(url.searchParams);
params.delete('sig');
const payload = `${url.pathname}?${params.toString()}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(env.SIGNING_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const sigBytes = hexToBytes(sig);
return await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload));
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}In the request handler:
if (!(await verifySignature(url, env))) {
return new Response('forbidden', { status: 403 });
}On your origin server (Node, Python, whatever), generate signed URLs:
import crypto from 'node:crypto';
function signOgUrl(title: string, author: string): string {
const exp = Math.floor(Date.now() / 1000) + 300; // 5 min
const params = new URLSearchParams({ title, author, exp: exp.toString() });
const payload = `/og?${params.toString()}`;
const sig = crypto.createHmac('sha256', process.env.SIGNING_SECRET!).update(payload).digest('hex');
params.set('sig', sig);
return `https://og.yourdomain.com/og?${params.toString()}`;
}Now nobody can hit your Worker except via URLs you've signed. Cache still works — the cache key includes the signature, but signed URLs reused within the expiry window all hit the same cache entry.
Step 7: KV storage for permanent OG cards
Cloudflare KV is a key-value store with global replication. Useful for OG images you want to persist beyond Cache API's eviction window.
wrangler kv:namespace create OG_STOREAdd to wrangler.toml:
[[kv_namespaces]]
binding = "OG_STORE"
id = "abc123..." # from the create command outputUpdate Env and the handler:
export interface Env {
SNAPSHARP_API_KEY: string;
SIGNING_SECRET: string;
OG_STORE: KVNamespace;
}
async function handleOgWithKv(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const cacheKey = `og:${url.search}`;
// Try KV first
const stored = await env.OG_STORE.get(cacheKey, { type: 'arrayBuffer' });
if (stored) {
return new Response(stored, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
'X-KV-Hit': 'true',
},
});
}
// ... call SnapSharp as before ...
const image = await apiResp.arrayBuffer();
ctx.waitUntil(
env.OG_STORE.put(cacheKey, image, {
expirationTtl: 60 * 60 * 24 * 30, // 30 days
}),
);
// ... return response ...
}KV reads cost $0.50 per million; writes are $5 per million. For OG cards that get scraped occasionally, the read cost is negligible.
Step 8: rate limiting with Cloudflare's API
Workers have access to cf properties on every request, including the country, ASN, and a unique IP hash. Rate limit by IP:
async function rateLimit(request: Request, env: Env): Promise<boolean> {
const ip = request.headers.get('cf-connecting-ip') ?? 'unknown';
const key = `rl:${ip}:${Math.floor(Date.now() / 60000)}`;
const count = parseInt((await env.OG_STORE.get(key)) ?? '0', 10);
if (count >= 60) return false;
await env.OG_STORE.put(key, String(count + 1), { expirationTtl: 120 });
return true;
}For higher-throughput rate limiting, use Cloudflare's WAF rate limiting rules — they're enforced before the Worker even runs.
Common pitfalls
Pitfall 1: forgetting ctx.waitUntil on cache puts. Without it, the runtime cancels the cache write when the response is sent. The first request after a cache miss won't actually populate the cache.
Pitfall 2: 100ms CPU time limit on Free plan. Workers Free has a 10ms CPU time limit per request (subrequests don't count). The Paid plan has 50ms. SnapSharp does the heavy lifting — your Worker just orchestrates. You're well under the limit.
Pitfall 3: caches.default doesn't share across edge nodes. Each Cloudflare PoP has its own cache. A request in Tokyo doesn't benefit from a cache hit in Frankfurt. KV gives you global replication but at a higher latency (10-50ms reads).
Pitfall 4: leaking the API key. Never put SNAPSHARP_API_KEY in wrangler.toml [vars]. Always use wrangler secret put so it's encrypted at rest.
Pitfall 5: 1MB response body limit on Workers Free. Compressed PNGs at 1200x630 are typically 50-150KB, but full-page screenshots at 4K can exceed 1MB. Keep dimensions reasonable for free-tier deploys, or upgrade to Workers Paid (no limit).
Final code
One file:
src/index.ts— Worker handler with Cache API, KV, signed URLs, and rate limiting.
Around 200 lines total. Compiles to a 30 KB bundle that runs at 300+ edge locations.
Conclusion
Cloudflare Workers + SnapSharp is the cheapest, fastest way to deliver per-page OG images at global scale. The Cache API absorbs >99% of traffic for free. KV handles long-tail persistence. Signed URLs prevent abuse. The whole setup deploys in under a minute and costs essentially zero for typical content sites.
Next steps: read the Vercel Edge Functions tutorial, explore the OG image API, or look at OG image best practices for design tips.
Related: Pricing · Custom OG templates · How to generate OG images in Next.js