Getting Started
OGImg renders dynamic Open Graph images from a single GET request. Pass a title, description, colors, or a saved template, and get back a cached PNG served from Cloudflare R2 — no headless browser to run or maintain yourself.
https://ogimg.netMCP server for AI agents and editors
npm · ogimg-mcpWorks with any MCP client (Claude Code, Cursor, Windsurf, VS Code, Codex CLI, Gemini CLI and more), so your agent can create OG images for you. Nothing to install: it runs with npx on Node.js 18 or later.
npx -y ogimg-mcpAuthentication
Every request needs your API key, found on your dashboard. Send it either as a header or a query parameter:
- Header:
x-api-key: YOUR_API_KEY - Query param:
?key=YOUR_API_KEY— the only option available for <meta> tags, since social crawlers can't send custom headers.
Security note: because og:image URLs are public and fetched by third-party crawlers without auth headers, your key is necessarily visible in your page source when used this way. Keep an eye on usage in the dashboard, and regenerate the key if it ever leaks.
Rotating a key without broken previews
An account can hold up to 5 active keys, all drawing from the same monthly quota (an extra key adds no quota). To replace a key, for example one that leaked:
- In the dashboard's API Keys page, create a new key and give it a name (e.g. the site that will use it).
- Put the new key in your site's configuration (environment variable, theme setting, or template) and deploy. Both keys work at this point, so nothing breaks.
- Watch the old key's Last used time. Once it stops changing, nothing calls it anymore (platforms that cached the old URL keep showing their stored copy).
- Revoke the old key. It stops working immediately and can't be restored. Your last active key can't be revoked, so an account is never left without a working key.
Regenerate is the fast, disruptive option. It replaces a key's secret in place: the old value answers 401 at once and every og:image still using it goes blank until you deploy the new value. Use it when the old key must stop working this instant; otherwise rotate as above. Neither option resets your quota. Keeping the key in one config value instead of pasted into every page makes either a one-line change.
Rate Limits & Quotas
Two independent layers apply:
- Monthly quota, per account and shared by all of its API keys, based on your plan: 500 (Free), 3,000 (Starter), 10,000 (Pro), or 100,000 (Agency) requests. Every request to your image URL counts, including repeats served from the cache. Requests that fail with a server error aren't counted. The counter resets automatically 30 days after you sign up, then 30 days after the first request that follows each reset — no manual reset needed. Creating, regenerating or revoking keys never changes it.
- IP-based limits, independent of your key: 20 requests per 10 seconds per IP, and an IP gets temporarily blocked after 10 invalid API key attempts within 5 minutes. These are counted per server instance, so treat the exact numbers as approximate.
Cached renders (identical parameters requested again) don't re-render, but still count against your quota. Requests that fail with a 500 or 503 don't.
Render Image
GET /api/render — renders a 1200×630 PNG by default (customizable via width/height) and redirects (302) to its CDN URL. Point a browser, an <img>, or a social crawler straight at it.
curl -L "https://ogimg.net/api/render?key=YOUR_API_KEY&title=Hello+World&description=Generated+by+OGImg" -o image.png
Parameters
All parameters are optional except your API key.
| Parameter | Type | Description |
|---|---|---|
| title | string | Main heading. Falls back to the target page's own title (see url below), then "Untitled". |
| description | string | Supporting text below the title. Also falls back to the target page's own description (see url below). |
| author | string | Small attribution line at the bottom. Also falls back to the target page's meta author (see url below). |
| url | https URL | Auto-fill title/description/logo from this page's own og:title, og:description, and og:image — anything you set explicitly always wins. Best-effort: silently skipped if the page is unreachable, times out, isn't HTML, or resolves to a private/internal address. |
| bg | hex color | Background color. Defaults to #050505. |
| textColor | hex color | Text color. Defaults to #ffffff. |
| layout | classic | minimal | centered | split | gradient | bold | Visual arrangement of the image. Defaults to classic. |
| pattern | none | dots | grid | diagonal | crosshatch | waves | Subtle tiled background texture, independent of layout. Defaults to none. |
| width | integer (100–2048) | Output width in px. Defaults to 1200. Scales the whole design proportionally — nothing reflows. Values outside 100–2048 are clamped to the nearest limit, not rejected. |
| height | integer (100–2048) | Output height in px. Defaults to 630. Combine with width for non-standard aspect ratios (e.g. square). Clamped the same way as width. |
| format | png | webp | Output image format. Defaults to png. webp is typically smaller for the same visual quality. |
| logo | https URL | Logo image shown top-left (position varies by layout). Must be an https:// URL; anything else is silently ignored. No logo online? Upload one in the dashboard's template editor and use the URL it gives you. Also falls back to the target page's og:image (see url below). |
| template | string | Slug of a saved template — see Templates below. |
| font | geist | inter | roboto_mono | playfair | poppins | Pro/Agency only. Free and Starter plans always render in Geist (the default) regardless of this value. |
Free plan renders include a small "Made with OGImg" watermark in the corner — there's no parameter to remove it. Starter, Pro and Agency plans render without one automatically, no code change needed on your end. See pricing.
Templates
Create a template in the template editor to save default title/description/author text, colors, layout, pattern, logo, and font. Pass its slug via ?template=, and any other query parameters you add override the saved defaults for that one request:
https://ogimg.net/api/render?template=my-blog-post&key=YOUR_API_KEY&title=Override+the+saved+title
On Pro and Agency plans, a template can also carry its own custom HTML/CSS instead of using the built-in layouts — write full markup in the template editor and it replaces the entire rendered page. Only {{title}}, {{description}}, and {{author}} are still substituted (HTML-escaped) — everything else in your markup renders as-is. JavaScript never runs in a render, so script-based tools such as the Tailwind Play CDN don't work: write plain CSS in a <style> block. This is a per-template setting, not a query parameter.
Also on Pro and Agency, and also per template rather than per request: upload a finished design made in another tool. The editor crops and scales any picture to exactly 1200×630 (PNG, JPG or WebP, up to 5 MB), and you choose whether the title and description are drawn on top of it. And the Gradient layout can use your own linear, radial, or conic gradient with up to five colors instead of the two shades derived from the background color.
Errors
| Status | Meaning |
|---|---|
| 401 | Missing or invalid API key. |
| 403 | The account's email address isn't verified yet. Confirm it from your dashboard to activate the key. |
| 404 | The template slug in ?template= doesn't exist for this key's account. |
| 429 | Monthly quota exceeded, or too many requests from your IP. |
| 500 | Rendering failed or timed out (5s render budget). |
| 503 | Server is at capacity — too many renders in progress at once. Safe to retry after a couple seconds (see the Retry-After header). |
Integrations: how it works on any site
There is no SDK, plugin, or script to install. The integration is a URL: you put an /api/render URL in the og:image tag of each page, and the social platform fetches it when someone shares the link. That works the same on every stack that can write a <meta> tag into the page's <head>.
- Build the URL for the page, encoding every value (see below).
- Write it into the page's
<head>asog:image, in the HTML your server sends. - Check it with
curland a platform debugger (see Testing).
<meta property="og:image" content="https://ogimg.net/api/render?key=YOUR_API_KEY&title=My+Blog+Post" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />Recommended full set. og:image:width/height let platforms lay out the preview before the image loads; twitter:card asks X for the large image card (X falls back to og:image when there is no twitter:image). If you change width/height, update these two tags to match.
<meta property="og:title" content="Tips & Tricks for Faster Builds" /> <meta property="og:image" content="https://ogimg.net/api/render?key=YOUR_API_KEY&title=Tips+%26+Tricks+for+Faster+Builds" /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta property="og:image:alt" content="Tips & Tricks for Faster Builds" /> <meta name="twitter:card" content="summary_large_image" />
Encode every value
Titles often contain characters that mean something in a URL. An unencoded & starts a new parameter (the rest of the title is lost), # cuts the URL off, and + turns into a space. Always build the query string with your language's encoder (URLSearchParams, http_build_query, urlencode, url_encode) instead of joining strings by hand, as every example below does.
Wrong: https://ogimg.net/api/render?key=YOUR_API_KEY&title=Tips & Tricks #1 Right: https://ogimg.net/api/render?key=YOUR_API_KEY&title=Tips+%26+Tricks+%231
The tags must be in the HTML your server sends. Social crawlers read the raw HTML and do not run JavaScript, so a tag added in the browser after the page loads is never seen. Server-rendered and statically generated pages are fine; client-only apps need the extra step described under React / Vue SPA.
Static HTML
For hand-written or generated static pages (Hugo, Jekyll, Eleventy output, plain HTML), write one tag per page with that page's title already encoded. Static site generators can fill the title from front matter with their own URL-encode filter.
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <title>About us</title> <meta property="og:title" content="About us" /> <meta property="og:image" content="https://ogimg.net/api/render?key=YOUR_API_KEY&title=About+us&layout=centered" /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta name="twitter:card" content="summary_large_image" /> </head> <body>...</body> </html>
Next.js
App Router (Next.js 13.2 and later): return the image from generateMetadata. Next.js renders it into the <head> on the server. Keep the key in a server-only environment variable (no NEXT_PUBLIC_ prefix needed); note it still appears in the page's HTML, as with any og:image URL.
// app/blog/[slug]/page.tsx (Next.js 15 and 16: params is a Promise) import type { Metadata } from "next"; export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }): Promise<Metadata> { const { slug } = await params; const post = await getPost(slug); // your own data loader const query = new URLSearchParams({ key: process.env.OGIMG_API_KEY!, title: post.title, description: post.excerpt, }); const ogImage = "https://ogimg.net/api/render?" + query.toString(); return { title: post.title, openGraph: { title: post.title, images: [{ url: ogImage, width: 1200, height: 630, alt: post.title }], }, twitter: { card: "summary_large_image" }, }; }
// Next.js 13.2 to 14: params is a plain object, not a Promise export async function generateMetadata({ params }: { params: { slug: string } }) { const post = await getPost(params.slug); // ...build ogImage exactly as above }
For a site-wide default, export metadata with the same openGraph.images from your root app/layout.tsx. Next.js replaces (doesn't merge) the whole openGraph object when a page sets its own, so repeat title and images in each page's metadata.
Pages Router: set the tags with next/head and build the URL in getStaticProps or getServerSideProps, so it is in the server HTML.
// pages/blog/[slug].tsx (Pages Router, any Next.js version) import Head from "next/head"; import type { GetStaticProps } from "next"; type Props = { post: { title: string }; ogImage: string }; // Built on the server, so the key stays out of the JavaScript bundle. export const getStaticProps: GetStaticProps<Props> = async ({ params }) => { const post = await getPost(String(params?.slug)); // your own data loader const ogImage = "https://ogimg.net/api/render?" + new URLSearchParams({ key: process.env.OGIMG_API_KEY!, title: post.title }).toString(); return { props: { post, ogImage } }; }; export default function Post({ post, ogImage }: Props) { return ( <> <Head> <title>{post.title}</title> <meta property="og:title" content={post.title} /> <meta property="og:image" content={ogImage} /> <meta property="og:image:width" content="1200" /> <meta property="og:image:height" content="630" /> <meta name="twitter:card" content="summary_large_image" /> </Head> {/* page content */} </> ); } // getServerSideProps works the same way; add getStaticPaths for dynamic routes.
React / Vue SPA (Vite, Create React App, Vue CLI)
A client-only single-page app sends the same nearly empty index.html for every route, and its JavaScript fills in the page later. Crawlers never run that JavaScript, so tags set with React Helmet, document.head, or useHead at runtime do not reach them. Pick one:
- One image for the whole app: put a fixed
og:imagetag directly inindex.html(see Static HTML). Simplest, but every page shares it. - Prerender at build time (for example a static-site plugin for your bundler): each route gets its own HTML file, so each can carry its own tag.
- Server-render: move to a framework that renders on the server (Next.js, Nuxt, Remix, Astro, SvelteKit), or have your server inject the tag into
index.htmlper route before sending it (see Node.js).
WordPress
Add the tag from a child theme's functions.php (or a small site plugin) so a theme update doesn't remove it. This works on any WordPress version that has the wp_head hook, i.e. all current ones.
<?php // wp-content/themes/your-child-theme/functions.php add_action( 'wp_head', function () { if ( ! is_singular() ) { return; } $url = 'https://ogimg.net/api/render?' . http_build_query( array( 'key' => 'YOUR_API_KEY', 'title' => get_the_title(), ) ); echo '<meta property="og:image" content="' . esc_attr( $url ) . '" />' . "\n"; echo '<meta property="og:image:width" content="1200" />' . "\n"; echo '<meta property="og:image:height" content="630" />' . "\n"; }, 5 );
SEO plugins print their own og:image. Yoast SEO, Rank Math, All in One SEO and Jetpack each output an og:image tag, and with two tags a platform may pick theirs. Either turn off that plugin's Open Graph image output, or paste the render URL into the plugin's per-post social image field instead of using the snippet above.
Laravel / PHP
In a Blade layout or view, build the URL with PHP's http_build_query (it encodes every value) and print it with {{ }}, which HTML-escapes it. In plain PHP, do the same and print it through htmlspecialchars.
{{-- config/services.php: 'ogimg' => ['key' => env('OGIMG_API_KEY')] --}}
@php
$ogImage = 'https://ogimg.net/api/render?' . http_build_query([
'key' => config('services.ogimg.key'),
'title' => $post->title,
]);
@endphp
<meta property="og:title" content="{{ $post->title }}">
<meta property="og:image" content="{{ $ogImage }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">Node.js / Express
Use the built-in URL class (Node.js 10 and later), which encodes values set through searchParams. Pass the result to your template engine (EJS, Pug, Handlebars, Nunjucks), which escapes it for HTML.
// Express (or any server that renders HTML) function ogImageUrl(title, description) { const url = new URL("https://ogimg.net/api/render"); url.searchParams.set("key", process.env.OGIMG_API_KEY); url.searchParams.set("title", title); if (description) url.searchParams.set("description", description); return url.toString(); } app.get("/blog/:slug", async (req, res) => { const post = await getPost(req.params.slug); // Pass ogImageUrl(post.title) to your template, and HTML-escape it there. res.render("post", { post, ogImage: ogImageUrl(post.title, post.excerpt) }); });
Python / Django
urllib.parse.urlencode from the standard library builds the query string. The same function works in Flask, FastAPI, or any other Python framework; only where you pass the value to the template differs.
# Python: any framework (Django shown) from urllib.parse import urlencode from django.conf import settings def og_image_url(title, description=None): params = {"key": settings.OGIMG_API_KEY, "title": title} if description: params["description"] = description return "https://ogimg.net/api/render?" + urlencode(params) # In the view: context["og_image"] = og_image_url(post.title) # In the template (Django escapes it for you): # <meta property="og:image" content="{{ og_image }}">
Shopify
In Online Store → Themes → Edit code, add the tag to layout/theme.liquid inside <head>. Liquid's url_encode filter encodes each value. Most themes already print an og:image from a snippet (meta-tags.liquid in Dawn-based themes, social-meta-tags.liquid in older ones): remove or wrap that one for the same pages, or the platform may pick the theme's image.
{% comment %} layout/theme.liquid, inside <head> {% endcomment %}
{% if template contains 'product' %}
<meta property="og:image" content="https://ogimg.net/api/render?key=YOUR_API_KEY&title={{ product.title | url_encode }}&description={{ shop.name | url_encode }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
{% endif %}Other hosted builders (Webflow, Wix, Squarespace, Framer) work when they let you add custom code to a page's <head>: paste a tag per page with the title already encoded, as in Static HTML. If a builder only accepts an uploaded share image, it can't use a dynamic URL.
Testing & Cache Refresh
Check the URL first, then how a platform sees the page. A working URL answers 302 with a location pointing at the stored image.
# 1. The API answers with a redirect to the stored image curl -sI "https://ogimg.net/api/render?key=YOUR_API_KEY&title=Hello" | grep -iE "^(HTTP|location)" # HTTP/2 302 # location: https://...png # 2. Download the image and open it curl -sL "https://ogimg.net/api/render?key=YOUR_API_KEY&title=Hello" -o og.png
- Facebook Sharing Debugger shows what Facebook (and WhatsApp, which uses the same tags) reads, and its Scrape Again button refreshes its copy.
- LinkedIn Post Inspector does the same for LinkedIn.
- The OG Checker on this site lists every Open Graph tag a page sends.
Two caches are involved. Platforms keep a preview for days, keyed on the image URL. OGImg stores each image keyed on the parameters you send, so a changed title means a new URL and a fresh image everywhere. Two cases don't change the URL:
- A platform still shows an old preview: use its debugger above to re-scrape, or add a parameter OGImg ignores, such as
&v=2, so the platform treats it as a new URL. - With
url=auto-fill, the title read from the target page is not part of the key: once an image is stored, a later change to that page's title doesn't produce a new one. Passtitleexplicitly when it changes over time.
Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
| No image in the preview at all | The tag isn't in the server HTML (client-only app, see React / Vue SPA), the URL is relative instead of absolute, or the page itself needs a login. View the page source (not the inspector) to confirm the tag is there. |
| Preview worked, then went blank | The key was regenerated or revoked, or the email is unverified (401/403), or the account's monthly quota ran out (429). Run the curl check above to see the status. |
| Title is cut off at “&” or “#” | The value wasn't URL-encoded. Build the query string with your language's encoder. |
| “+” or “%” shows up wrong in the title | The value was encoded twice, or not at all. Encode exactly once. |
| The old image keeps showing | The platform's cache. Re-scrape with its debugger, or append &v=2. |
| Two different images compete | A theme or SEO plugin prints its own og:image. Keep only one tag per page. |
| “Made with OGImg” in the corner | Free plan watermark. It disappears on Starter, Pro and Agency with no URL change. |
| font= has no effect | Custom fonts are Pro/Agency only; other plans render in Geist. |
| Occasional 503 | Too many new images rendering at once. It is safe to retry after the Retry-After header; images already stored are never affected. |
Versions & Compatibility
- API version. There is one version of the API,
GET /api/render, with no version number in the path. The parameters on this page are what it accepts today; unknown parameters are ignored rather than rejected. - Your stack. Anything that can write a
<meta>tag into server-sent HTML works, regardless of language or version. The examples above note where versions matter: Next.js App Router needs 13.2 or later forgenerateMetadata, andparamsbecame a Promise in Next.js 15. - Redirect. The URL answers
302to the stored image. The major platforms follow it. If a tool you use does not, copy thelocationfrom the curl check and use that image URL directly. It then no longer updates or counts against your quota, and stops working some time after your account is deleted. - Format. Keep the default PNG for social previews. WebP files are smaller, but not every platform shows WebP previews, so use
format=webponly where you control the consumer, such as images on your own pages. - Size. 1200×630 (the default) is the size Facebook, LinkedIn and X expect for large previews. Other sizes work for your own uses.
Custom Fonts (Pro)
Free and Starter plans always render in Geist. Pro and Agency plans can set font (or save one on a template) to one of: geist (the default), inter, roboto_mono, playfair, or poppins.
AI Agents and Editors (MCP Server)
OGImg ships an MCP server exposing a generate_og_image tool. MCP is an open standard, so it works with any AI agent or editor that supports MCP servers: Claude Code, Cursor, Windsurf, VS Code, OpenAI Codex CLI, Gemini CLI, Claude Desktop, Cline, Zed and more. Ask the agent to add a dynamic OG image to a page, and it can call /api/render directly and drop the resulting URL straight into your meta tags, without you leaving the editor. See Features for an overview.
MCP server for AI agents and editors
npm · ogimg-mcpWorks with any MCP client (Claude Code, Cursor, Windsurf, VS Code, Codex CLI, Gemini CLI and more), so your agent can create OG images for you. Nothing to install: it runs with npx on Node.js 18 or later.
npx -y ogimg-mcpUse a dedicated API key for your agent, so you can revoke it without touching your sites. Every client starts the same command, npx -y ogimg-mcp, with the key in OGIMG_API_KEY; only where you write that down differs:
claude mcp add ogimg -e OGIMG_API_KEY=YOUR_API_KEY -- npx -y ogimg-mcp
codex mcp add ogimg --env OGIMG_API_KEY=YOUR_API_KEY -- npx -y ogimg-mcp # or by hand, in ~/.codex/config.toml (TOML: mcp_servers, not mcpServers) [mcp_servers.ogimg] command = "npx" args = ["-y", "ogimg-mcp"] env = { "OGIMG_API_KEY" = "YOUR_API_KEY" }
{
"mcpServers": {
"ogimg": {
"command": "npx",
"args": ["-y", "ogimg-mcp"],
"env": { "OGIMG_API_KEY": "YOUR_API_KEY" }
}
}
}The JSON goes in .cursor/mcp.json (Cursor), ~/.codeium/windsurf/mcp_config.json (Windsurf), .vscode/mcp.json (VS Code, which names the top-level object servers), ~/.gemini/settings.json (Gemini CLI) or claude_desktop_config.json (Claude Desktop). Other clients use the same mcpServers shape; see their MCP settings. Keep the file out of version control if it holds your key. The tool returns the stored image URL rather than a URL containing your key; each call counts as one request against your monthly quota.
Agent without MCP support? It can still use OGImg: the API is a single GET request, so any agent that writes code or fetches URLs can build the render URL itself, with no package at all.