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.

Base URL: https://ogimg.net

MCP server for AI agents and editors

npm · ogimg-mcp

Works 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-mcp

Authentication

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:

  1. In the dashboard's API Keys page, create a new key and give it a name (e.g. the site that will use it).
  2. 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.
  3. 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).
  4. 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
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.

ParameterTypeDescription
titlestringMain heading. Falls back to the target page's own title (see url below), then "Untitled".
descriptionstringSupporting text below the title. Also falls back to the target page's own description (see url below).
authorstringSmall attribution line at the bottom. Also falls back to the target page's meta author (see url below).
urlhttps URLAuto-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.
bghex colorBackground color. Defaults to #050505.
textColorhex colorText color. Defaults to #ffffff.
layoutclassic | minimal | centered | split | gradient | boldVisual arrangement of the image. Defaults to classic.
patternnone | dots | grid | diagonal | crosshatch | wavesSubtle tiled background texture, independent of layout. Defaults to none.
widthinteger (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.
heightinteger (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.
formatpng | webpOutput image format. Defaults to png. webp is typically smaller for the same visual quality.
logohttps URLLogo 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).
templatestringSlug of a saved template — see Templates below.
fontgeist | inter | roboto_mono | playfair | poppinsPro/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:

Example
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

StatusMeaning
401Missing or invalid API key.
403The account's email address isn't verified yet. Confirm it from your dashboard to activate the key.
404The template slug in ?template= doesn't exist for this key's account.
429Monthly quota exceeded, or too many requests from your IP.
500Rendering failed or timed out (5s render budget).
503Server 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>.

  1. Build the URL for the page, encoding every value (see below).
  2. Write it into the page's <head> as og:image, in the HTML your server sends.
  3. Check it with curl and a platform debugger (see Testing).
Minimal
<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.

HTML: recommended tags
<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.

Encoding
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.

HTML
<!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.

Next.js 15 / 16 (App Router)
// 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 – 14 (App Router)
// 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.

Next.js (Pages Router)
// 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:image tag directly in index.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.html per 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 (WordPress)
<?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.

Blade
{{-- 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.

Node.js
// 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
# 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.

Liquid
{% 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.

Terminal
# 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

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. Pass title explicitly when it changes over time.

Troubleshooting

SymptomLikely cause and fix
No image in the preview at allThe 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 blankThe 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 titleThe value was encoded twice, or not at all. Encode exactly once.
The old image keeps showingThe platform's cache. Re-scrape with its debugger, or append &v=2.
Two different images competeA theme or SEO plugin prints its own og:image. Keep only one tag per page.
“Made with OGImg” in the cornerFree plan watermark. It disappears on Starter, Pro and Agency with no URL change.
font= has no effectCustom fonts are Pro/Agency only; other plans render in Geist.
Occasional 503Too 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 for generateMetadata, and params became a Promise in Next.js 15.
  • Redirect. The URL answers 302 to the stored image. The major platforms follow it. If a tool you use does not, copy the location from 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=webp only 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-mcp

Works 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-mcp

Use 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 Code
claude mcp add ogimg -e OGIMG_API_KEY=YOUR_API_KEY -- npx -y ogimg-mcp
OpenAI Codex CLI
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" }
Cursor, Windsurf, VS Code, Gemini CLI, Claude Desktop and other MCP clients (JSON config)
{
  "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.