Vercel's Edge Runtime runs your code on Cloudflare-based edge nodes, close to users. For OG images — small, cacheable, requested once per share — this is the right shape. The cold start is a few hundred milliseconds, the cache absorbs everything after, and you don't pay for idle.
This tutorial walks through building Edge Functions that wrap SnapSharp. By the end you'll have a /api/og endpoint that takes URL parameters, returns a designed PNG, and caches at the edge. Pair it with Next.js or use it standalone — the pattern works either way.
Prerequisites
- Node.js 20+ and pnpm.
- A Vercel account with a deployed project.
- A free SnapSharp API key from snapsharp.dev/sign-up.
- Familiarity with Edge Runtime constraints (no Node APIs, only Web standards).
Step 1: project setup
If you're starting fresh:
pnpm create next-app@latest snap-edge --ts --eslint --app
cd snap-edgeOr use any framework that supports Vercel Edge Functions. The pattern transfers cleanly to Astro, SvelteKit, Remix, or a standalone Vercel project (just api/og.ts at the root).
Add SNAPSHARP_API_KEY to your env:
echo 'SNAPSHARP_API_KEY=sk_live_YOUR_API_KEY' >> .env.localIn Vercel's dashboard, add the same env var to Production + Preview environments under Settings → Environment Variables.
Step 2: a basic Edge Function
app/api/og/route.ts (Next.js App Router):
import { NextRequest } from 'next/server';
export const runtime = 'edge';
const SNAPSHARP_BASE = 'https://api.snapsharp.dev/v1';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || 'Untitled';
const author = searchParams.get('author') || '';
const date = searchParams.get('date') || '';
const tag = searchParams.get('tag') || '';
const apiKey = process.env.SNAPSHARP_API_KEY;
if (!apiKey) {
return new Response('SNAPSHARP_API_KEY not configured', { status: 500 });
}
const apiResp = await fetch(`${SNAPSHARP_BASE}/og-image`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'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();
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, s-maxage=31536000, stale-while-revalidate=86400',
},
});
}A few notes about the Edge Runtime:
export const runtime = 'edge'switches Next.js from Node to Edge. The function bundle is smaller, cold starts are faster, and you lose access to Node-specific APIs (fs,child_process, etc.).- The SnapSharp Node SDK has a Node-specific entry. For Edge Functions, call the HTTP API directly with
fetch— that's what we do above. s-maxage=31536000tells Vercel's CDN to cache for a year. The Edge Function is invoked once per unique URL until the cache expires.
Deploy with vercel deploy. Test:
curl 'https://your-project.vercel.app/api/og?title=Hello%20World&author=Jane' -o og.png
open og.pngStep 3: integrate with Next.js metadata
Reference the OG endpoint from your page's metadata. In app/blog/[slug]/page.tsx:
import type { Metadata } from 'next';
import { getPost } from '@/lib/posts';
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) return {};
const ogParams = new URLSearchParams({
title: post.title,
author: post.author,
date: post.date,
tag: post.tags?.[0] ?? '',
});
const ogUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/api/og?${ogParams.toString()}`;
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: ogUrl, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
images: [ogUrl],
},
};
}When social platforms scrape the post, they read og:image, fetch your /api/og endpoint, and the Edge Function returns a PNG. After the first scrape, Vercel's CDN serves the cached version globally.
Step 4: cache control and revalidation
The Cache-Control header above is the heart of the pattern. Let's break it down:
max-age=86400— browsers cache the response for 24 hours.s-maxage=31536000— Vercel's CDN caches for 1 year.stale-while-revalidate=86400— after expiry, serve stale while quietly refreshing.
For an OG card that reflects a blog post, this is fine. The post's content rarely changes once published. If you do change the post and want a fresh OG card, version the URL by including a hash or updated timestamp:
const ogUrl = `/api/og?title=${title}&v=${post.updatedAt}`;A different query string is a different cache key — Vercel will fetch a fresh image once.
Step 5: signed URLs to prevent abuse
If you publish your /api/og endpoint, anyone can call it with arbitrary parameters and drain your SnapSharp quota. Sign URLs server-side and verify in the Edge Function.
The Edge Runtime has crypto.subtle for HMAC. Helpers:
// app/api/og/sign.ts
async function hmac(secret: string, payload: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload));
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export async function signOgUrl(params: Record<string, string>, secret: string): Promise<string> {
const exp = Math.floor(Date.now() / 1000) + 300;
const sp = new URLSearchParams({ ...params, exp: String(exp) });
const payload = sp.toString();
const sig = await hmac(secret, payload);
sp.set('sig', sig);
return `?${sp.toString()}`;
}
export async function verifyOgUrl(url: URL, secret: string): 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 sp = new URLSearchParams(url.searchParams);
sp.delete('sig');
const expected = await hmac(secret, sp.toString());
return timingSafeEqual(sig, expected);
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}In the OG handler:
import { verifyOgUrl } from './sign';
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const secret = process.env.SIGNING_SECRET;
if (secret && !(await verifyOgUrl(url, secret))) {
return new Response('forbidden', { status: 403 });
}
// ... rest of handler ...
}In generateMetadata:
import { signOgUrl } from '@/app/api/og/sign';
const signed = await signOgUrl({ title, author, date, tag }, process.env.SIGNING_SECRET!);
const ogUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/api/og${signed}`;Now only URLs your server has signed are valid. Cache still works because the same parameters always produce the same signature within an exp window.
Step 6: live URL screenshots at the edge
Same pattern, different endpoint. app/api/screenshot/route.ts:
import { NextRequest } from 'next/server';
export const runtime = 'edge';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const target = searchParams.get('url');
if (!target) return new Response('url required', { status: 400 });
if (!isValidPublicUrl(target)) {
return new Response('invalid url', { status: 400 });
}
const apiResp = await fetch(
`https://api.snapsharp.dev/v1/screenshot?` +
new URLSearchParams({
url: target,
width: '1280',
height: '720',
format: 'png',
block_ads: 'true',
}),
{
headers: { Authorization: `Bearer ${process.env.SNAPSHARP_API_KEY}` },
},
);
if (!apiResp.ok) return new Response('capture failed', { status: 502 });
const image = await apiResp.arrayBuffer();
return new Response(image, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400, s-maxage=2592000',
},
});
}
function isValidPublicUrl(input: string): boolean {
try {
const u = new URL(input);
if (!['http:', 'https:'].includes(u.protocol)) return false;
const host = u.hostname;
if (host === 'localhost') return false;
if (/^(127\.|10\.|192\.168\.|169\.254\.)/.test(host)) return false;
return true;
} catch {
return false;
}
}Visit /api/screenshot?url=https://github.com and you get a screenshot served from the edge. Cache hits across regions are basically free.
Step 7: regional pinning
Edge Functions run in the region closest to the user by default. To pin to a specific region (e.g., closer to SnapSharp's EU origin to minimize backend latency on cache misses):
export const runtime = 'edge';
export const preferredRegion = ['fra1', 'cdg1']; // Frankfurt, ParisCache-hit responses still serve from the user's nearest region. Only cache-miss invocations run in the preferred region, where they have a faster path to SnapSharp's origin.
Step 8: monitoring and observability
Vercel's Edge Logs show every invocation. Filter by route to track OG generation patterns:
vercel logs --followFor deeper observability, send logs to your stack of choice (Axiom, Datadog, Better Stack):
async function logEvent(event: object) {
if (!process.env.AXIOM_TOKEN) return;
await fetch('https://api.axiom.co/v1/datasets/edge-og/ingest', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.AXIOM_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([{ ...event, timestamp: new Date().toISOString() }]),
});
}Wrap your handler:
const start = Date.now();
const response = await /* ... */;
ctx.waitUntil(logEvent({
duration: Date.now() - start,
status: response.status,
cache: response.headers.get('x-vercel-cache'),
}));x-vercel-cache: HIT means Vercel served from cache; MISS means your function ran. Watch the ratio. Above 95% HIT means caching is working.
Common pitfalls
Pitfall 1: importing Node-only modules. The Edge Runtime doesn't have fs, path, crypto (the Node version), process.env for non-system vars, etc. Stick to Web standards. The crypto.subtle API replaces Node's crypto.
Pitfall 2: response size limits. Vercel Edge has a 4.5MB response body limit. Most OG cards (1200x630 PNG) are under 100KB, but full-page screenshots at 4K can hit the limit. For large screenshots, use Node runtime instead.
Pitfall 3: 30-second timeout. Edge Functions have a 30-second total runtime cap (5 seconds free tier, 30s on Pro). Long captures (full_page=true on huge pages) can exceed it. Use SnapSharp's /v1/async/screenshot for jobs that need more time.
Pitfall 4: forgetting s-maxage. Without it, only the browser caches. Every cold visitor triggers a SnapSharp call. s-maxage is what tells Vercel's CDN to cache.
Pitfall 5: leaking the API key in client components. Edge Functions are server-side, so process.env.SNAPSHARP_API_KEY is fine. But if you accidentally use it in a Client Component ('use client'), Next.js bundles it into the browser. Always use the env var only in Edge / Node API routes.
Final code
Two files:
app/api/og/route.ts— Edge Function for OG images.app/api/screenshot/route.ts— Edge Function for screenshots.- (Optional)
app/api/og/sign.ts— HMAC signing helpers.
Around 150 lines total. Deploys to Vercel's edge network globally.
Conclusion
Vercel Edge Functions + SnapSharp is the simplest production-grade OG image setup you can build today. No browser binaries, no font management, no complex caching infrastructure. Vercel handles geographic distribution. SnapSharp handles rendering. You write 50 lines of code and ship.
Next steps: explore the Cloudflare Workers tutorial for a similar pattern with different tradeoffs, read OG image best practices, or look at Next.js App Router screenshots for the full Next.js integration.
Related: Pricing · Custom OG templates · How to generate OG images in Next.js