Dynamic OG image API for Node.js and Python
Open Graph images are the one piece of metadata that actually moves the needle. A good og:image can double social-share click-through rate. But generating them well is annoying: 1200×630, PNG, 2× DPI for Retina, network-idle wait so lazy hero images finish loading, no cookie banner in your preview, no ad injected mid-capture.
Miss any one of those and your card ends up blurry, cropped, or featuring a GDPR banner where the headline should be.
Yesterday we shipped POST /v1/og-image: one endpoint that hard-codes all of those defaults and lets you throw any URL at it. This post shows the Node.js, Python, and framework-specific integrations, plus how the automatic caching saves you money at scale.
What the endpoint does
The whole spec fits in a few lines:
POST https://api.getsnap.dev/v1/og-image
X-API-Key: sk_live_...
Content-Type: application/json
{
"url": "https://blog.example.com/why-rust"
}
You get back:
{
"url": "https://cdn.getsnap.dev/og-images/ab12/req_XYZ.png",
"cached": false,
"request_id": "req_XYZ",
"response_time_ms": 1834,
"width": 1200,
"height": 630
}
That's it. Drop the returned url straight into your <meta property="og:image"> tag and every scraper — Facebook, LinkedIn, Slack, Discord, Twitter's summary_large_image card — renders it correctly.
The defaults baked in for you (all overrideable):
- 1200×630 PNG, the size every major platform recommends.
- 2× device scale. On Retina the difference between a 1× and 2× render is obvious the moment you look at it.
- Wait for network idle + lazy-load trigger. Hero images that live behind an IntersectionObserver are usually the entire point of your card. We wait for them.
- Popup + ad blocking. Nobody wants their preview card to feature "This site uses cookies. Accept all?"
- Reduced-motion. If your hero has a slick
<video autoplay>, we capture a stable frame instead of a half-drawn one. - 7-day CDN cache. Facebook and LinkedIn poll aggressively; long
Cache-Control: immutableshifts that traffic off our infrastructure and onto DigitalOcean's edge.
Node.js example (using the SDK)
The getsnap SDK on npm ships with typed methods for every endpoint. Install:
npm install getsnap
And a minimal call:
import GetSnap from "getsnap";
const snap = new GetSnap(process.env.GETSNAP_KEY!);
const { url, cached, response_time_ms } = await snap.ogImage({
url: "https://blog.example.com/why-rust",
});
console.log(url); // https://cdn.getsnap.dev/og-images/.../req.png
console.log(cached); // false on first call, true on repeats
console.log(response_time_ms); // 1800ms fresh, 8ms cached
Cache hits do NOT consume your monthly quota. This is important later.
Or the raw fetch, if you don't want a dependency
const res = await fetch("https://api.getsnap.dev/v1/og-image", {
method: "POST",
headers: {
"X-API-Key": process.env.GETSNAP_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ url: "https://blog.example.com/why-rust" }),
});
const { url } = await res.json();
Both patterns hit the same endpoint and return the same shape.
Python example
The getsnap package on PyPI mirrors the Node.js API:
pip install getsnap
import os
from getsnap import GetSnap
snap = GetSnap(api_key=os.environ["GETSNAP_KEY"])
result = snap.og_image(url="https://blog.example.com/why-rust")
print(result["url"]) # CDN URL
print(result["cached"]) # False first time, True thereafter
print(result["response_time_ms"])
Same billing, same cache, same response shape. If you're on Django or FastAPI, this is one function call from your view.
Next.js App Router integration
If you're on Next.js 14+, the natural place to call this is generateMetadata. Next runs it at build time (or on-demand-revalidation for ISR), so the OG image resolves before the HTML ever ships.
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import GetSnap from "getsnap";
const snap = new GetSnap(process.env.GETSNAP_KEY!);
export async function generateMetadata({ params }: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
const canonical = `https://myapp.com/blog/${post.slug}`;
let ogImage = "https://myapp.com/default-og.png";
try {
const r = await snap.ogImage({ url: canonical });
ogImage = r.url;
} catch (err) {
console.warn("OG generation failed, using default:", err);
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: ogImage, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt,
images: [ogImage],
},
};
}
Note the try/catch with a static fallback. Screenshot APIs are HTTP APIs; they can time out or 500. Ship a hand-designed default OG and fall back to it on any error — social platforms never see a broken image URL.
Nuxt 3 integration
Nuxt has useHead() which merges into your document head reactively. Put the call in asyncData equivalent so it happens once at load:
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`);
const canonical = `https://myapp.com/blog/${route.params.slug}`;
const { data: og } = await useFetch("/api/og", {
query: { url: canonical },
});
useHead({
title: post.value.title,
meta: [
{ property: "og:title", content: post.value.title },
{ property: "og:description", content: post.value.excerpt },
{ property: "og:image", content: og.value.url },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:image", content: og.value.url },
],
});
</script>
And the server route (server/api/og.get.ts):
import GetSnap from "getsnap";
const snap = new GetSnap(process.env.GETSNAP_KEY!);
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const url = String(query.url);
try {
return await snap.ogImage({ url });
} catch {
return { url: "https://myapp.com/default-og.png" };
}
});
Astro integration
Astro's build-time model is a perfect fit — run the OG generation once during astro build, ship the URL in the static HTML, done.
---
// src/pages/blog/[slug].astro
import Layout from "@/layouts/BlogLayout.astro";
import GetSnap from "getsnap";
import { getEntryBySlug } from "astro:content";
const snap = new GetSnap(import.meta.env.GETSNAP_KEY);
const { slug } = Astro.params;
const post = await getEntryBySlug("blog", slug!);
const canonical = new URL(`/blog/${slug}`, Astro.site).toString();
let ogImage = new URL("/default-og.png", Astro.site).toString();
try {
const r = await snap.ogImage({ url: canonical });
ogImage = r.url;
} catch {}
---
<Layout title={post.data.title} description={post.data.excerpt} ogImage={ogImage}>
<post.render() />
</Layout>
Why the cache changes the economics
OG images have a beautiful cache profile: they change when the source page changes, and social scrapers hit them thousands of times per share.
Our /v1/og-image endpoint keys the cache off every parameter that would affect the output. Same URL + same viewport = same PNG returned from cache. The cache lookup runs in about 8 ms.
Concretely: if you publish 20 posts a month and each gets 500 shares, here's what actually gets billed:
| Event | Count | Credits used |
|---|---|---|
| First call per post (build or first share) | 20 | 20 |
| Subsequent shares (all cache hits) | 9,980 | 0 |
| Total for the month | 10,000 | 20 |
20 credits per month fits in the free tier (100/mo). If you're publishing 5× more or want the trial, Starter ($9/mo, 5,000 credits) covers 250,000 shares.
Every response includes X-Cache: HIT/MISS and X-Credits-Remaining, so you can watch the ratio in real time.
When to use /v1/og-image vs. a custom template
Two patterns work for OG generation. Pick the one that matches your content model:
| Pattern | Best for |
|---|---|
| Screenshot the real page (this endpoint) | Content that is already visually strong: product pages, docs, landing pages, screenshots-of-your-app content. The card looks like the actual page. |
| Screenshot a template (custom HTML) | Content that is text-heavy or where the actual page doesn't have a strong hero: blog posts, changelog entries, release notes. Design a small HTML template with your logo + title + subtitle. |
Related tutorial
If the "custom template" pattern fits your content better, see Generate Open Graph images with an HTML template — from scratch. Same billing, same caching, same CDN.
How this compares to alternatives
OG image generation is a crowded market. The other options fall into three camps:
- Vercel OG / Satori (JSX-based, edge-runtime). Free with Vercel, but locked to Next.js/Nuxt and constrained to what Satori can render (no
<canvas>, limited CSS). Works only for template-style images, not real page screenshots. - Cloudinary / Bannerbear / Placid. Full-featured but priced per image at commercial scale. $99–299/mo for the same throughput you'd get on our Starter plan.
- DIY (Playwright yourself). Free until you count operational cost. Chromium in a Lambda uses 500 MB of memory, cold-starts in 2–3 seconds, and requires a queue to prevent concurrent captures from exhausting the runtime. You'll spend a week on it before it works reliably.
Where /v1/og-image lands: no framework lock-in, a real API (not just a template renderer), 4–12× cheaper than Cloudinary/Bannerbear per capture, and no queue plumbing to maintain. Cache hits are 0 credits so at any real scale you're paying only for genuinely new URLs.
One more trick: the binary response mode
By default the endpoint returns JSON with a CDN URL. But if you want to serve the image inline — e.g. as an <img> source directly from your API without a redirect — pass response_type: "binary":
POST /v1/og-image
{
"url": "https://blog.example.com/why-rust",
"response_type": "binary"
}
// Response:
// Content-Type: image/png
// Cache-Control: public, max-age=604800, immutable
// (raw PNG bytes)
This mode is useful when you're building a proxy or don't want CDN URLs in your HTML. Same billing.
Recap
One POST, one URL in, a CDN link out. Everything you'd want turned on by default. Free tier for hobby, Starter for real. Full Swagger UI at api.getsnap.dev/docs.
Generate your first OG image in 30 seconds
Grab a free API key, run one curl, drop the URL in a <meta> tag. Done.