Open Graph images are the thumbnail that appears when someone shares your link on Twitter, LinkedIn, Slack, or iMessage. A well-designed OG image can increase click-through rates by 2–3x. This tutorial shows you how to generate dynamic, per-page OG images in Next.js using the SnapSharp API — no Canvas, no Puppeteer, no server setup.
Why OG images matter more than you think
When you share a URL on social media, platforms fetch the og:image meta tag and display it as a preview card. Without an image, you get a blank card. With a generic static image, every link looks identical. With per-page dynamic images, every blog post, product, or user profile gets a custom card that reflects its content.
Numbers that make this concrete:
- Tweets with images receive 150% more retweets than text-only tweets (Buffer, 2023)
- LinkedIn posts with images get 2x higher engagement rates
- Proper OG images make shared links look professional and trustworthy — absence signals a neglected site
The result: dynamic OG images are one of the highest-ROI improvements you can make to content-driven sites, and they're underused because the implementation is traditionally painful.
The problem with the traditional approaches
Option 1: Static OG image
The simplest option. Set one image for your entire site:
<meta property="og:image" content="https://your-site.com/og.png" />It works, but every page shares the same thumbnail. Your blog post about "TypeScript generics" and your article about "Tailwind CSS tips" look identical when shared.
Option 2: Canvas-based generation
Generate images server-side with Node.js canvas or node-canvas. Powerful, but painful — binary dependencies, build complexity, font loading issues, and a blank canvas to draw on. You're writing low-level 2D drawing code for every layout change.
Option 3: Puppeteer / Playwright
Spin up a headless Chrome, render an HTML page, screenshot it. This gives you full CSS layout, but headless browsers are memory-hungry (250MB+ each), slow to start, and expensive to keep warm in serverless environments. Lambda cold starts with Playwright can take 3–5 seconds.
Option 4: Vercel OG (@vercel/og)
Vercel's Edge Runtime OG image solution using Satori. Great if you're fully on Vercel and happy with its JSX-based subset of CSS. Limitations: no full CSS support, no web fonts by default, only works in Edge Runtime, 1.5MB output size limit, and no Tailwind classes by default.
Option 5: API-based (SnapSharp)
Pass template variables, get back a pixel-perfect JPEG or PNG. The rendering, browser pool, caching, and CDN are handled for you. Works anywhere — Vercel, AWS, Railway, bare metal, or local dev. No vendor lock-in.
Setting up SnapSharp
Install the SDK (or skip it — plain fetch works fine):
pnpm add @snapsharp/sdkGet your API key at snapsharp.dev/api-keys. The free tier gives you 100 requests/month — enough to prototype and test.
Available OG templates
SnapSharp ships five built-in templates:
| Template | Best for |
|---|---|
blog-post | Blog articles, tutorials, changelog entries |
product-card | SaaS features, product pages, landing sections |
social-card | Twitter/LinkedIn profile cards, announcements |
quote-card | Pull quotes, testimonials, key stats |
github-readme | Open source projects, documentation pages |
Each template accepts template-specific variables plus shared options (format, quality, width, height). You can also design fully custom templates with your own HTML/CSS in the dashboard.
Generating OG images in Next.js App Router
Create a dedicated route handler at app/og/route.ts:
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
const API_URL = 'https://api.snapsharp.dev/v1/og-image';
export const runtime = 'nodejs'; // use 'edge' if you're on Vercel Edge
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const title = searchParams.get('title') ?? 'Untitled';
const author = searchParams.get('author') ?? '';
const date = searchParams.get('date') ?? '';
const tag = searchParams.get('tag') ?? '';
const res = await fetch(API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SNAPSHARP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: 'blog-post',
data: { title, author, date, tag },
format: 'jpeg',
quality: 85,
}),
// Revalidate at the CDN level — images don't change often
next: { revalidate: 86400 },
});
if (!res.ok) {
// Return a fallback 1x1 pixel image rather than breaking the page
return new NextResponse(null, { status: 404 });
}
const buffer = await res.arrayBuffer();
return new NextResponse(buffer, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
},
});
}Wiring up metadata in your blog post page
In app/blog/[slug]/page.tsx:
import type { Metadata } from 'next';
interface Props {
params: { slug: string };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug); // your data fetching
const siteUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://your-site.com';
const ogParams = new URLSearchParams({
title: post.title,
author: post.author,
date: new Date(post.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
tag: post.tags?.[0] ?? '',
});
const ogUrl = `${siteUrl}/og?${ogParams.toString()}`;
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: [
{
url: ogUrl,
width: 1200,
height: 630,
alt: post.title,
},
],
type: 'article',
publishedTime: post.date,
authors: [post.author],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
images: [ogUrl],
},
alternates: {
canonical: `/blog/${params.slug}`,
},
};
}Using custom HTML templates
If the built-in templates don't fit your brand, you can design your own. Create a template in the SnapSharp dashboard with your brand fonts, colors, and layout. Then reference it by ID:
body: JSON.stringify({
template_id: 'tmpl_your_custom_template_id',
data: {
title,
subtitle: post.description,
logo_url: `${siteUrl}/logo.svg`,
bg_color: '#0f172a',
},
format: 'jpeg',
}),The template system uses Handlebars syntax ({{title}}, {{#if tag}}, etc.), so you can conditionally render elements based on the data you pass.
Caching strategy
OG images are a perfect candidate for aggressive caching because they rarely change. Here's the recommended stack:
1. SnapSharp built-in cache — SnapSharp caches identical requests in Redis for up to 1 hour by default. Same parameters = instant response from cache.
2. Next.js fetch cache — the next: { revalidate: 86400 } option in your fetch call tells Next.js to cache the API response at the server level for 24 hours.
3. Cache-Control header — the public, max-age=86400 header tells browsers and CDNs to cache the image. stale-while-revalidate=604800 serves the old image while regenerating, eliminating any latency.
4. CDN cache — if you're on Vercel or behind Cloudflare, the CDN will cache based on your Cache-Control headers, meaning subsequent requests don't even hit your server.
This four-layer caching means: the first request for a slug takes ~500ms to generate; every subsequent request for the same slug is served instantly from CDN.
Testing OG images locally
OG images are notoriously annoying to test because social platforms cache them aggressively. Best approach for local development:
- Visit
http://localhost:3000/og?title=My+Post+Title&author=John+Doedirectly in the browser to see the image - Use a social preview tool like opengraph.xyz to test without triggering Twitter's cache
- Use ngrok or Cloudflare Tunnel to expose your local dev server to test with real social scrapers
For Twitter specifically, use the Card Validator — note that it caches aggressively, so change the URL query param slightly to bust the cache between tests.
Common mistakes
Forgetting the og:image:width and og:image:height tags. Some scrapers require dimensions to display the image correctly. Always include them:
images: [{ url: ogUrl, width: 1200, height: 630, alt: post.title }]Using PNG instead of JPEG for photos/gradients. JPEG is 30–50% smaller for photographic content and complex gradients. Use format: 'png' only for images with text-heavy content where you need crisp edges with transparency.
Encoding the URL without encodeURIComponent. Titles with ampersands, quotes, or special characters break the URL. Always use URLSearchParams or encodeURIComponent.
Not setting a fallback. If the API call fails, return a fallback so your page doesn't break. A 404 from the /og route is fine — social crawlers will just show no preview, which is better than an error.
Generating images client-side. OG images must be available at a static URL that social crawlers can fetch. They're crawled server-side by bots, not rendered in the user's browser.
Product pages and e-commerce
The same pattern works for any page type. For a product page:
const ogParams = new URLSearchParams({
title: product.name,
price: `$${product.price}`,
badge: product.badge ?? '',
image_url: product.imageUrl,
});And use the product-card template which renders a clean layout with price, product image, and badge.
Performance tips
- Use
format: 'jpeg'andquality: 85— gives excellent visual quality at ~40% smaller file size than PNG - Set
cache: truein the API request body to enable SnapSharp's server-side cache (on by default) - Use
next: { revalidate }to cache at the Next.js data layer - Set long
max-ageon Cache-Control for images with stable content
Using the Pages Router (Next.js 12–13)
If you're on an older Next.js project that hasn't migrated to App Router, the pattern is nearly identical. Create pages/api/og.ts:
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { title = 'Untitled', author = '', date = '', tag = '' } = req.query as Record<string, string>;
const response = await fetch('https://api.snapsharp.dev/v1/og-image', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SNAPSHARP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: 'blog-post',
data: { title, author, date, tag },
format: 'jpeg',
quality: 85,
}),
});
if (!response.ok) {
res.status(404).end();
return;
}
const buffer = await response.arrayBuffer();
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800');
res.send(Buffer.from(buffer));
}Then in pages/blog/[slug].tsx, generate the OG URL in getStaticProps or getServerSideProps and include it in your <Head>:
import Head from 'next/head';
// In your page component:
const ogUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/og?title=${encodeURIComponent(post.title)}&author=${encodeURIComponent(post.author)}`;
return (
<>
<Head>
<meta property="og:image" content={ogUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogUrl} />
</Head>
{/* page content */}
</>
);The Pages Router approach works just as well. The only difference is cache revalidation — with pages/api, you rely on Cache-Control headers and CDN behavior rather than Next.js's built-in fetch cache.
Multiple OG templates per site
Different page types benefit from different OG images. Here's how to route to the right template based on page type:
// app/og/route.ts
import type { NextRequest } from 'next/server';
type PageType = 'blog' | 'product' | 'profile' | 'default';
const TEMPLATE_MAP: Record<PageType, string> = {
blog: 'blog-post',
product: 'product-card',
profile: 'social-card',
default: 'social-card',
};
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const type = (searchParams.get('type') ?? 'default') as PageType;
const template = TEMPLATE_MAP[type] ?? 'social-card';
// Build data based on template type
const data: Record<string, string> = { title: searchParams.get('title') ?? 'Untitled' };
if (type === 'blog') {
data.author = searchParams.get('author') ?? '';
data.date = searchParams.get('date') ?? '';
data.tag = searchParams.get('tag') ?? '';
} else if (type === 'product') {
data.price = searchParams.get('price') ?? '';
data.badge = searchParams.get('badge') ?? '';
data.image_url = searchParams.get('image_url') ?? '';
}
const res = await fetch('https://api.snapsharp.dev/v1/og-image', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SNAPSHARP_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ template, data, format: 'jpeg', quality: 85 }),
next: { revalidate: 86400 },
});
if (!res.ok) return new Response(null, { status: 404 });
const buffer = await res.arrayBuffer();
return new Response(buffer, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
},
});
}Then in each page's generateMetadata:
// For blog: /og?type=blog&title=...&author=...
// For product: /og?type=product&title=...&price=...
// For profile: /og?type=profile&title=...This single /og route handles all your page types without duplicating the fetch logic.
Handling special characters and long titles
Long titles and special characters are the most common sources of broken OG images. A few defensive patterns:
Truncate before encoding. Social platforms often crop images without warnings. Truncate titles to 60–70 characters before passing to the template:
const title = post.title.length > 65
? post.title.slice(0, 62) + '...'
: post.title;Always use URLSearchParams. Never concatenate query strings manually:
// Correct — handles ampersands, quotes, Unicode
const ogParams = new URLSearchParams({ title, author, date });
// Wrong — breaks on & and special characters
const ogUrl = `/og?title=${title}&author=${author}`;Test with adversarial inputs. Titles with quotes ("), slashes (/), plus signs (+), and emoji are the most common edge cases. Add them to your staging OG test suite.
Monitoring OG image generation
OG images are a background concern — they don't block page loads, and failures are silent unless you monitor them. Recommended approach:
-
Log 4xx/5xx from your
/ogroute handler to your error tracking tool (Sentry, Axiom, Datadog). An uptick in 404s from the/ogroute means your metadata is pointing to invalid URLs. -
Track cache hit rate. SnapSharp returns
X-Cache: HIT/MISSon every response. If your hit rate is below 80%, your cache TTL may be too short or you're sending unique parameters on every request. -
Weekly social preview audit. Use a tool like opengraph.xyz or a custom script to check a sample of your most-shared pages. OG meta tags break in subtle ways after refactors (canonical URL changes, environment variable changes, etc.).
# Quick CLI check for a page
curl -s "https://your-site.com/og?title=Test+Post&author=Jane" \
-o /dev/null \
-w "Status: %{http_code}, Size: %{size_download} bytes, Time: %{time_total}s\n"Frequently Asked Questions
What's the difference between @vercel/og and SnapSharp for OG images?
@vercel/og uses Satori to render a JSX-subset of CSS at the edge — it's fast but supports only a limited subset of CSS, no web fonts by default, no Tailwind classes out of the box, and locks you to Vercel's Edge Runtime. SnapSharp renders real HTML/CSS in headless Chromium, so anything your browser can render works (including complex gradients, Tailwind, custom fonts, and SVGs).
What image dimensions should I use for OG images?
1200×630 is the canonical size that works everywhere (Facebook, LinkedIn, Slack, iMessage). Twitter also accepts 1200×675 for summary_large_image. Always set og:image:width and og:image:height meta tags to match — some scrapers require them.
How do I invalidate the OG image cache after updating a post?
SnapSharp caches identical requests for 1 hour by default. To force regeneration, either wait for the TTL to expire, change any request parameter (e.g. append a v=2 to the data), or send cache: false in the request body. Social platforms (Twitter, Facebook) have their own scraper caches — use their debugger tools to re-scrape.
Can I use SnapSharp OG image generation in Next.js Edge Runtime?
Yes. The route handler makes a plain fetch call, which works in both edge and nodejs runtimes. Edge is faster to cold-start but has smaller payload limits; nodejs runtime is fine for most use cases and has no body size limit.
Why is my OG image showing old content on Twitter/LinkedIn?
Social platforms cache OG images aggressively — often for days. Use the Twitter Card Validator or LinkedIn Post Inspector to force a re-scrape. For Facebook, use the Sharing Debugger.
Do I need a different OG image for every blog post, or is one generic image enough?
Per-post OG images drive 2–3x higher click-through rates than a generic site-wide image. If budget is tight, start with per-post for high-traffic pages (top 10 posts) and use a static fallback for the rest. The blog-post template makes per-post generation a one-line change in generateMetadata().
Conclusion
Dynamic OG images are one of those improvements that feel like polish but have real business impact. Every shared link becomes a branded, content-specific card instead of a blank square.
The SnapSharp approach takes about 30 minutes to set up, works on any hosting platform, and costs a fraction of maintaining your own headless browser infrastructure. The free tier gives you 100 images per month to prototype — enough to cover a small blog.
Related: OG Image docs · Pricing · Design Custom OG Image Templates with HTML & CSS · Site Audit: Extract Design Tokens from Any Website