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

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:

EventCountCredits used
First call per post (build or first share)2020
Subsequent shares (all cache hits)9,9800
Total for the month10,00020

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:

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

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.

Get free API key