Open Graph Image API for Articles
Generate unique 1200×630 Open Graph images per article with Next.js ImageResponse, caching guidance, crawler fixes, and a ScreenshotNeo option.
Direct answer: In Next.js App Router, add app/blog/[slug]/opengraph-image.tsx and return new ImageResponse(...). Next.js emits the article’s og:image metadata. The default output is a 1200×630 PNG.
How the pipeline works
- A crawler requests your article.
- Next.js points
og:imageat the route-segment image. - The route loads the slug and renders JSX.
@vercel/og, Satori and Resvg convert it to PNG, as described in the Next.js guide.
Generate one image per article
import { ImageResponse } from 'next/og'
import { notFound } from 'next/navigation'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
async function getArticle(slug: string) {
const data = { title: 'Open Graph Image API for Articles', author: 'Editorial team', category: 'Engineering' }
return slug ? data : null
}
export default async function Image({ params }: { params: { slug: string } }) {
const article = await getArticle(params.slug)
if (!article) notFound()
return new ImageResponse((<div style={{width:'100%',height:'100%',display:'flex',flexDirection:'column',justifyContent:'space-between',padding:72,background:'#101827',color:'white',fontFamily:'Arial'}}><div style={{display:'flex',fontSize:28,color:'#93c5fd'}}>{article.category}</div><div style={{display:'flex',flexDirection:'column',gap:24}}><div style={{display:'flex',fontSize:64,lineHeight:1.08,fontWeight:700}}>{article.title}</div><div style={{display:'flex',fontSize:30,color:'#cbd5e1'}}>By {article.author}</div></div></div>), size)
}
Use flexbox and explicit dimensions. Satori supports a constrained CSS subset; CSS Grid and arbitrary browser CSS are not guaranteed. See the ImageResponse reference.
Metadata and data
import type { Metadata } from 'next'
export async function generateMetadata({ params }): Promise<Metadata> {
const article = await getArticle(params.slug)
return article ? { title: article.title, openGraph: { type: 'article', images: [{ url: `/blog/${params.slug}/opengraph-image` }] } } : {}
}
The file convention usually supplies the image tag automatically. Replace the sample lookup with your CMS, handle missing slugs with notFound(), and decide whether that data is cached.
Static versus dynamic generation
| Mode | Best for | Trade-off |
|---|---|---|
| Static file or build output | Metadata changes only on deploy | Fast and predictable, but requires a rebuild |
| Dynamic route | Titles or scores change after deploy | Fresh data adds a render and data dependency |
Next.js statically optimizes generated images unless Dynamic APIs or uncached data are used. Add deliberate revalidation or cache headers for dynamic routes.
ImageResponse options and limits
width/heightdefault to 1200/630.fontsaccepts bundled font name, weight, style and binary data.emojiselects an emoji set;debughelps diagnose rendering.status,statusTextandheaderscontrol the HTTP response.
Keep the response a public image URL; crawlers do not run your page’s client JavaScript.
Fetch and verify the deployed route
cURL
curl -L 'https://example.com/blog/open-graph-image-api/opengraph-image' -o og.png
Python
import requests
r = requests.get('https://example.com/blog/open-graph-image-api/opengraph-image', timeout=30)
r.raise_for_status()
open('og.png', 'wb').write(r.content)
print(r.headers.get('content-type'))
Node.js
const res = await fetch('https://example.com/blog/open-graph-image-api/opengraph-image');
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const fs = await import('node:fs/promises');
await fs.writeFile('og.png', Buffer.from(await res.arrayBuffer()));
Inspect the article HTML for an absolute og:image URL, then use each network’s share debugger because crawlers cache independently.
Deployment checklist
- Use a public HTTPS deployment and return a 2xx image response.
- Allow the route in
robots.txt; Vercel’s example notes that social providers must fetch OG API routes. - Do not put authentication, geo blocks or bot challenges in front of the image.
- Redeploy or purge caches after changing fonts, titles or layout.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| No preview | Private or blocked route, or a non-2xx response. Fetch it unauthenticated, allow robots and inspect logs. |
| 500 error | Missing article or unsupported JSX/CSS. Handle notFound(), remove Grid and enable debug. |
| Stale title | Build or crawler cache. Choose revalidation deliberately, redeploy and trigger a debugger refresh. |
| Missing font | Bundle font bytes through fonts; do not rely on server-installed fonts. |
| Slow generation | Cache CMS data, keep JSX small and embed only needed fonts. |
Performance, reliability and cost
Build-time output avoids per-share rendering. Dynamic output is fresher but needs cache policy and resilient data access. Measure cold and warm requests in your deployment; no universal latency or conversion benchmark exists. Keep a static fallback if sharing is business-critical.
Or skip the browser setup
ScreenshotNeo captures rendered pages through a GET API and offers an MCP server. Read the API docs.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/blog/open-graph-image-api -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/blog/open-graph-image-api"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/blog/open-graph-image-api' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, newsletter popups and chat widgets are removed before capture. Bot checks, blank pages and failed loads are never billed; X-Page-Verdict and X-Billed identify the result. MCP tools include take_screenshot, get_page_info and capture_pdf. The free plan gives 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free account.
FAQ
Can generated images be JPEG?
Static convention files can be JPEG; ImageResponse generates PNG.
Will normal CSS libraries work?
Only their properties supported by Satori. Prefer flexbox, explicit sizes and bundled fonts.
Why do networks disagree?
Each crawler caches separately; verify the URL and request a refresh in its debugger.


