Auto-Generate OG Images for a Next.js Blog — Tutorial With Code
Every blog post on a Next.js site needs a 1200×630 Open Graph image. The default is to open Figma, drop the title into a template, export the PNG, drop it into /public/, and reference it in generateMetadata. It takes ten minutes per post, and it's the reason most blogs end up shipping without a custom OG image at all.
This walkthrough replaces that step with a single POST to the ThumbAPI endpoint. You send the post title, get back a 1200×630 PNG or WebP, cache it to /public/og/{slug}.png, and reference it in the route's generateMetadata. Under 30 seconds per post, zero design work, and it slots into any Next.js App Router blog.
Grab a free API key and generate your first OG image
Get Started FreeThe Problem
A Next.js blog usually renders MDX files or CMS-backed posts through a dynamic route like app/blog/[slug]/page.tsx. The route exports generateMetadata to set title, description, and openGraph.images. The blocker is always the same: what image URL do you put in openGraph.images?
The three usual answers all have costs. Manual design per post costs hours per month. Reusing one generic image kills social CTR — every share looks identical. Vercel's dynamic ImageResponse API works, but you still design the JSX layout by hand and every card looks like a variation of the same template.
The Workflow
A single generate-once, cache-forever pipeline runs at build time or on the first request for a given slug:
- Trigger. A blog post is added or updated (new MDX file, new CMS entry).
- Check cache. The Next.js route (or a build script) checks whether
/public/og/{slug}.pngalready exists. - Call ThumbAPI. If not, POST the post title and
format: "blogpost"tohttps://api.thumbapi.dev/v1/generate. Response is a base64 data URI in theimagefield. - Write to disk. Decode the base64 payload and write the PNG to
/public/og/{slug}.png. Now Next.js serves it as a static asset. - Reference in metadata.
generateMetadatareturnsopenGraph.images: ["/og/{slug}.png"].
The result: every share on X, LinkedIn, Slack, Discord, iMessage, or Facebook picks up a unique OG image, and your build stays fast because the image is served as a static file after the first generation.
Code Example — Build Script
The cleanest wiring is a small Node script that runs before next build. It walks your MDX/CMS posts, generates any missing OG images, and writes them to /public/og/. Drop this into scripts/generate-og-images.mjs:
import { readdir, readFile, writeFile, mkdir, access } from "node:fs/promises";
import { join } from "node:path";
import matter from "gray-matter"; // npm i gray-matter
const BLOG_DIR = "content/blog";
const OUT_DIR = "public/og";
const API_URL = "https://api.thumbapi.dev/v1/generate";
const API_KEY = process.env.THUMBAPI_API_KEY;
async function exists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function generate(title) {
const res = await fetch(API_URL, {
method: "POST",
headers: {
"x-api-key": API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
format: "blogpost",
category: "tech-saas",
outputFormat: "png",
}),
});
if (!res.ok) throw new Error(`ThumbAPI ${res.status}: ${await res.text()}`);
const { image } = await res.json();
const base64 = image.startsWith("data:") ? image.split(",", 2)[1] : image;
return Buffer.from(base64, "base64");
}
await mkdir(OUT_DIR, { recursive: true });
const files = (await readdir(BLOG_DIR)).filter((f) => f.endsWith(".mdx"));
for (const file of files) {
const slug = file.replace(/\.mdx$/, "");
const outPath = join(OUT_DIR, `${slug}.png`);
if (await exists(outPath)) continue;
const { data } = matter(await readFile(join(BLOG_DIR, file), "utf8"));
console.log(`Generating OG for ${slug} — "${data.title}"`);
const png = await generate(data.title);
await writeFile(outPath, png);
}
Wire it into package.json:
{
"scripts": {
"og:generate": "node scripts/generate-og-images.mjs",
"prebuild": "npm run og:generate",
"build": "next build"
}
}Now every next build generates missing OG images before compiling. Existing posts are skipped — the cache is the filesystem.
Code Example — Referencing in generateMetadata
In your dynamic post route, point openGraph.images and twitter.images at the cached PNG:
import type { Metadata } from "next";
import { getPostBySlug } from "@/lib/blog";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
const ogUrl = `/og/${slug}.png`;
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: [{ url: ogUrl, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
images: [ogUrl],
},
};
}
On-Demand Route Handler (Alternative)
If you'd rather generate on first request instead of at build time — useful for CMS-driven blogs where posts appear without a rebuild — put the same call behind a Route Handler and cache to disk on the server:
import { NextResponse } from "next/server";
import { getPostBySlug } from "@/lib/blog";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const post = await getPostBySlug(slug);
const res = await fetch("https://api.thumbapi.dev/v1/generate", {
method: "POST",
headers: {
"x-api-key": process.env.THUMBAPI_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: post.title,
format: "blogpost",
category: "tech-saas",
outputFormat: "png",
}),
// Next.js Data Cache — one generation per slug, keyed by URL
next: { revalidate: false, tags: [`og:${slug}`] },
});
const { image } = await res.json();
const base64 = image.startsWith("data:") ? image.split(",", 2)[1] : image;
const png = Buffer.from(base64, "base64");
return new NextResponse(new Uint8Array(png), {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
Then reference /api/og/{slug} instead of /og/{slug}.png in generateMetadata. The Data Cache holds the ThumbAPI response, and the response Cache-Controlheader lets Vercel's CDN cache the PNG at the edge for a year. You can bust a specific card with revalidateTag("og:my-slug") when a post title changes.
Customization
The API takes a handful of parameters relevant to a Next.js blog. Full list in the generate endpoint reference:
| Parameter | What it does |
|---|---|
format | blogpost (1200×630) is the OG standard. Also x, linkedin, instagram, youtube. |
category | One of 13 niche hints (tech-saas, business-finance, education-tutorial, etc.). Biases style toward the niche. |
outputFormat | png or webp. WebP is ~40% smaller, PNG is safer for older social crawlers. |
useLogo | Overlays your saved brand logo. Set once in Assets → Logo, then pass true per request. |
customAssetsId | Points at a saved reference set (Pro plan). Every OG image stays in your site's visual style without designing a template. |
Cost at Scale
A standard OG image on the blogpost format costs 10 credits. Because the pipeline caches to disk (or to the Next.js Data Cache), you pay once per post, not once per page view.
- 100 posts — 1,000 credits. Covered by the Pro plan ($49/mo, 2,500 credits) with room to spare.
- 1,000 posts — 10,000 credits. Covered exactly by the Business plan ($199/mo).
- 10,000 posts — 100,000 credits. Talk to us for a custom plan.
For a typical dev blog (10–20 posts per month), the Free tier (50 credits / month, no credit card) covers 5 new posts and the Creator plan ($19/mo)covers 75. Existing posts don't re-bill unless their title changes.
Handling Title Changes
If a post title changes, the cached OG image is stale. Two options:
- Build script: add a small hash of the title to the filename (
og/{slug}-{titleHash}.png). Old files get orphaned but nothing breaks. - Route Handler: call
revalidateTag("og:{slug}")from the CMS webhook that fires on post update. Next request regenerates.
FAQ
Does this work with the Next.js Pages Router?
Yes — swap generateMetadata for next/head in _document or per page. The build script and API call are framework-independent.
Do I need a Vercel deployment?
No. The build script writes to /public/og/, which any Node host serves as static assets. The Route Handler path uses the Next.js Data Cache and works on Vercel, self-hosted Node, or any Fluid Compute-compatible platform.
What about MDX posts imported at runtime?
Use the Route Handler variant. The Data Cache holds the ThumbAPI response so you pay for each unique slug once, not once per visitor.
Can I use my own brand style?
Yes — upload 1–6 reference thumbnails as a custom asset set and pass its ID via customAssetsId. Every OG image inherits your visual style.
Is the free tier enough to test this?
Yes. 50 credits per month covers 5 posts on the blogpostformat, and there's no credit card required. See the pricing page for the full breakdown.
Ship OG images for every post in your Next.js blog
Get Your Free API KeyRelated Pages
Product page for the OG image endpoint — formats, output types, and framework-agnostic examples.
Blog Cover APISame endpoint, framed for blog covers instead of social share cards.
Automated Content CreationHow ThumbAPI slots into a full content automation pipeline — not just the OG image.
QuickstartGet a key, make your first call, see the response shape. Under 5 minutes.
Generate Endpoint ReferenceFull parameter list, response shape, and error codes.