Guide

How to Generate OG Images for Every Page

Most blogs and docs sites start with one default og:image, so every shared link shows the same card. To get a distinct image on every page without making each one by hand, something has to render an image per page. That work can run in four places: your build, a function in your app, a hosted template service, or a hosted service that reads the page URL.

The short answer: generate at build time when your pages are known at build and you want no vendor. Use a request-time function when your app already runs on a server. Use a hosted service when you want neither an image pipeline nor a runtime, and pick between template and URL-based services by how much of the layout you want to control.

Where it runs and what you configure are separate choices

Most comparisons sort OG image tools into "build time" or "on demand". Many hosted services generate on demand too. Placid's URL API says your image "is generated as soon as it is first requested", Bannerbear's Instant URLs render images on the fly from URL parameters, and Cloudinary creates a derived image the first time its transformation URL is requested. Those are on demand too; they just run on someone else's servers.

So there are two questions:

  • Placement: where the image is rendered (your build machine, your server, or a vendor's servers) and when (before deploy, or when a crawler first asks).
  • Configuration: what you set up so each page gets its own image (a layout in code, a template with mapped fields, or nothing beyond the page itself).

This guide is about placement: what each option needs from your hosting and what you end up maintaining. For the configuration question for site owners, see URL-based OG generation versus template automation.

Compare the four options

Where per-page OG image generation runs, and what each option asks of you
Build timeRequest-time functionHosted template APIHosted URL-based
ExamplesSatori or Playwright script, SSG plugins, prerendered Astro endpoints Next.js opengraph-image, @vercel/og, nuxt-og-image Bannerbear, Placid, Cloudinary text overlaysMyOG
Runs onYour build machineYour server or serverless functionsVendor serversVendor servers
Works on a static hostYes; images are filesOnly for images rendered during the buildYes; the tag points at the vendorYes; the tag points at the vendor
Page data comes fromYour content files or CMS at buildRoute params and your data layerFields you send via API or URL parametersThe public page MyOG fetches
Layout limitsYour renderer: Satori's CSS subset, or a full browser with PlaywrightSatori via ImageResponse: flexbox, ttf/otf/woff, 500 KB bundleWhat the template editor supportsBuilt-in layouts; detail depends on what the page exposes
You maintainThe script, fonts, and build stepThe image route, fonts, and cachingTemplates and the field mappingReadable pages with accurate metadata
Image changes whenYou rebuildDepends on caching; Next.js caches by defaultDepends on the vendor's cachingA visit after the 7-day cache window
Cost modelBuild minutesFunction usage on your hostVendor credits or planMyOG plan credits
Failure shows upAt build, before deployWhen a crawler requests the imageWhen a crawler requests the image, or when your API call failsWhen a crawler requests the image

Pricing changes, so check the vendor pages (Bannerbear, Placid, MyOG) instead of trusting a number in an article.

Build time: render images into files before deploy

A build-time generator renders one PNG per page and writes it next to the rest of your static output. The page then points at a plain file. There is no runtime and no vendor, and a broken image shows up as a failed build instead of a broken share.

The script below uses Satori to turn a layout into SVG and @resvg/resvg-js to turn the SVG into PNG. I ran it with Satori 0.63.2 and resvg-js 2.6.2; it wrote two 1200×630 PNGs. Replace the posts array with your content and the font with a TTF, OTF, or WOFF file you are licensed to use.

// scripts/generate-og-images.mjs — run before your site build
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { Resvg } from "@resvg/resvg-js"
import satori from "satori"

const posts = [
  { slug: "hello-world", title: "Hello, world" },
  { slug: "static-export-og", title: "OG images on a static export" },
]
const font = await readFile("fonts/YourFont.ttf")

await mkdir("public/og", { recursive: true })
for (const post of posts) {
  const svg = await satori(
    {
      type: "div",
      props: {
        style: {
          display: "flex",
          boxSizing: "border-box",
          width: "100%",
          height: "100%",
          padding: 80,
          alignItems: "flex-end",
          background: "#111827",
          color: "white",
          fontSize: 72,
        },
        children: post.title,
      },
    },
    {
      width: 1200,
      height: 630,
      fonts: [{ name: "YourFont", data: font, weight: 400, style: "normal" }],
    }
  )
  const png = new Resvg(svg).render().asPng()
  await writeFile(`public/og/${post.slug}.png`, png)
}

Each page then gets a static tag pointing at its file:

<meta property="og:image" content="https://example.com/og/hello-world.png" />

One thing I hit while testing: without boxSizing: "border-box", current Satori added the 80px padding to the 100% width and height. The root box grew to 1360×790 and the title rendered below the 630px canvas, so the PNG came out blank. Identical markup inside Next.js ImageResponse rendered correctly. Next.js 16.4.3 bundles @vercel/og 1.0.1 with Satori 0.29.0, a different version from the one I installed.

That version gap matters for CSS and fonts too. The Satori README now lists Flexbox, Grid, and block layout and TTF, OTF, WOFF, and WOFF2 fonts. The Next.js ImageResponse docs still say "Only flexbox and a subset of CSS properties are supported" and "Only ttf, otf, and woff font formats are supported." Check the docs for the package you import.

Framework options work the same way. Astro's docs say that in static sites, "custom endpoints are called at build time to produce static files", and they can generate images. nuxt-og-image has a zero-runtime mode for images that only change at build time; its docs warn that "A page that is not prerendered gets no og:image tag".

The costs: build time grows with page count, an image only changes when you rebuild, and the script, fonts, and layout are yours to maintain. A Playwright screenshot of an HTML template gives you full browser CSS, at the price of running a browser in your build.

Request-time functions: an image route in your app

The second option is a route in your own app that returns an image. In Next.js, an opengraph-image.tsx file next to a page is a special route handler, and Next.js adds the og:image tags for you. The Next.js docs say these images are "statically optimized (generated at build time and cached) unless they use Request-time APIs or uncached data." So "request-time" in Next.js often still means rendered once at build.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og"
import { posts } from "@/lib/posts"

export const size = { width: 1200, height: 630 }
export const contentType = "image/png"

// Needed for output: "export".
// The page.tsx in this folder needs its own copy too.
export function generateStaticParams() {
  return posts.map((post) => ({ slug: post.slug }))
}

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = posts.find((p) => p.slug === slug)
  return new ImageResponse(
    (
      <div
        style={{
          display: "flex",
          width: "100%",
          height: "100%",
          padding: 80,
          alignItems: "flex-end",
          background: "#111827",
          color: "white",
          fontSize: 72,
        }}
      >
        {post?.title}
      </div>
    ),
    size
  )
}

I built this with Next.js 16.4.3 and output: "export", with metadataBase set to https://example.com in the root layout. The exported HTML for /blog/hello-world had these tags:

<meta property="og:image" content="https://example.com/blog/hello-world/opengraph-image?869ae7954dc58ab8"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:image:width" content="1200"/>
<meta property="og:image:height" content="630"/>

ImageResponse uses @vercel/og, Satori, and Resvg. Its documented limits are a 500 KB bundle (your JSX, CSS, fonts, and images), flexbox and a CSS subset, and ttf/otf/woff fonts. On Vercel, the OG image docs list support on the Node.js runtime and say @vercel/og adds CDN cache headers.

A function is the right place when the image depends on data that only exists at request time, or on private data you would never put on a public page. It also means the image route, fonts, and cache behavior are part of your app.

Static hosts and static export

On a static host, there is no server to run a function when a crawler asks. Next.js static export still works for OG images, but only for images it can render during the build. These are the failures I reproduced with Next.js 16.4.3:

  • Without generateStaticParams() on the page, the build failed with Page "/blog/[slug]" is missing "generateStaticParams()" so it cannot be used with "output: export" config.
  • With it on the page but not in opengraph-image.tsx, the build failed with export const dynamic = "force-static"/export const revalidate not configured on route "/blog/[slug]/opengraph-image" with "output: export".
  • With both, the build wrote out/blog/hello-world/opengraph-image, a 1200×630 PNG with no file extension.

The missing extension matters on hosts that pick the content type from the filename. Next.js issue #82177, still open, reports GitHub Pages serving these files as octet-stream instead of an image. Check the Content-Type your host sends for that path before you share links.

The static exports guide is specific about what breaks. Route handlers are supported: a GET handler "will render a static response when running next build". What is unsupported are "Route Handlers that rely on Request", "Dynamic Routes without generateStaticParams()", and anything else that needs request-time data. Pages added after the build get no image until the next build.

A hosted service sidesteps the build and runtime constraints, because the page only carries an image URL. It does not fix tags that are missing from the HTML: if your site is a client-rendered SPA, crawlers still need server rendering or prerendering to see any tag. The Vue prerendering guide covers one way to do that.

Hosted template APIs: a vendor renders your template

With a template service, you design a layout in the vendor's editor and send the values for each page. The Bannerbear API takes "a template uid and a list of template modifications" and also offers Instant URLs bound to one template. Placid's URL API lets you put a generated image URL straight into Open Graph tags, and recommends calling new image URLs at least once. Cloudinary can add text overlays in a transformation URL and caches the derived image on its CDN.

You get precise control over the design and nothing to run yourself. What you maintain is the template and the mapping from each page's data to its fields or URL parameters. If the image URL carries the page title in a query parameter, your page template still has to build that URL for every page.

Hosted URL-based generation: the page URL is the input

The fourth option skips the field mapping. I build MyOG, which works this way: each page's og:image points at a MyOG URL that contains the page's own URL. When a crawler requests the image, MyOG fetches the page, reads the title and description the page already publishes, uses favicon and font signals, and can include a page screenshot. The tag looks like this:

<meta property="og:image" content="https://api.myog.social/og?a=YOUR_ACCOUNT_HINT&url=https%3A%2F%2Fexample.com%2Fblog%2Fhello-world&template=screenshot-right" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://api.myog.social/og?a=YOUR_ACCOUNT_HINT&url=https%3A%2F%2Fexample.com%2Fblog%2Fhello-world&template=screenshot-right" />

Your dashboard and the platform guides generate this tag for your stack, with the page URL filled in by your template. The a value is a public account hint that identifies your installation; it carries no secret. The template parameter picks one of the built-in layouts: brand, screenshot, screenshot-right, screenshot-bottom, or screenshot-bottom-framed.

The requirements and limits, so you can rule it out quickly:

  • You need a MyOG account, the exact hostname registered (www.example.com and example.com are different hosts), and available credits. Without them, the image URL returns an error instead of an image: 404 for an unregistered hostname, 402 when no credits are left. That includes images MyOG generated before.
  • Pages must be public and readable by a bot. Login walls, bot protection, or a failed screenshot reduce what MyOG can use, and some layouts fall back to a domain card.
  • You choose from built-in layouts. Detected colors and fonts may not match your brand guidelines exactly. If you need exact placement, use a template service or your own code.
  • Images are cached. By default, requests within 7 days of generation get the cached image, so a changed page can show its older image for up to that long. There is no live refresh.
  • The tag has to be in the server HTML. MyOG does not make client-side meta tags visible to crawlers.

It fits when your pages already have good titles and you want every page covered without an image pipeline, a runtime, or a template to keep in sync. It works on static hosts because nothing runs on your side.

What every option still needs

  • The og:image tag in the HTML your server returns, not added by JavaScript.
  • An absolute image URL. Next.js builds it from metadataBase; static sites need the full domain in the tag.
  • A test against the deployed page. Social crawlers cannot fetch localhost; the OG image test guide covers what to check.
  • Patience with platform caches. Facebook, LinkedIn, and chat apps keep old previews; the cache guide has the refresh steps.

How to choose

  • Generate at build time when your pages are known at build and you want no vendor.
  • Use a request-time function when your app already runs on a server, or the image needs request-time or private data.
  • Use a hosted template API when you want exact brand layouts without running image code.
  • Use hosted URL-based generation when you want every public page covered from its own content, with no layout or field mapping to maintain.

Before you pick, measure how many pages currently share one default image. This sitemap script lists them using the free local checker. To see what a page's tags and card look like today, paste it into the checker.

2170061aae9a0ead7c222e25a54af7a4001eea6f