Menu
verceledge-functionstutorialog-imagescreenshot-api

Vercel Edge Functions OG Image Tutorial — Per-Page Cards in Minutes

SnapSharp Team·April 26, 2026·5 min read

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-edge

Or 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.local

In 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:

  1. 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.).
  2. 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.
  3. s-maxage=31536000 tells 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.png

Step 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, Paris

Cache-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 --follow

For 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

Vercel Edge Functions OG Image Tutorial — Per-Page Cards in Minutes — SnapSharp Blog