# Generating Open Graph images automatically for every page

> Stop making share images by hand. Build one 1200×630 template, render it from the page title at build time or on request, cache it, and check the result with a free link preview tool.

Updated 2026-09-25 · Social previews · HTML version: https://getreport.app/guides/generating-og-images-automatically

A site with 400 posts has 400 share images, or it has one generic logo card that every post shows in Slack and LinkedIn. Making them by hand does not scale; nobody opens Figma for a changelog entry. The alternative is a template: the page title, a background and your logo go in, a 1200×630 image comes out, and a cache keeps it cheap. This guide covers the four ways to do that, from a build-time route to a WordPress filter, and how to check the result. Plan two hours for the first version.

## Quick answer

- Design one template at **1200×630 px**: title in the middle 80 % of the width, logo in a corner, a background that reads at 300 px wide.
- Render it from the title, not by hand: a Next.js or Astro image route at build time, a small `/og?title=` service, an image CDN with text overlays, or a WordPress filter that points `og:image` at a generator.
- Serve it from an **absolute https URL** that answers 200 with a real JPEG, PNG or WebP under 1 MB.
- Cache aggressively (a day to a year) and change the URL when the template changes; social networks cache the old picture otherwise.
- Keep a static default image for pages the generator cannot handle.
- Run the [link preview checker](https://getreport.app/tools/link-preview) on three pages to confirm the size and that the image can be fetched.

## Why generated images matter

The preview card is often the only thing a reader sees before deciding to tap. A card with a picture takes several times the space of a bare link in a feed or chat, and a picture with the title on it survives the cropping that Facebook, LinkedIn and WhatsApp apply differently. Per-post images with a consistent frame also make a site recognisable in a feed: the same colour band, the same logo position, a different headline each time.

Hand-made images fail for a simpler reason: they stop. The designer makes ten, the eleventh post ships without one, and from then on every new page falls back to the site default, or to nothing. A generator never forgets, and it applies a redesign to every page at once.

Automation also fixes the two errors the link preview checker sees most: the image is too small (a 600 px featured image reused as the share image) or it cannot be fetched (a relative path, an `http://` URL on an HTTPS page, a file behind a login). A template always exports at 1200×630, and a route always answers at a known public address.

## How getReport checks it

> **Free tool:** [Link preview checker (Open Graph)](https://getreport.app/tools/link-preview): See how your page looks when shared on Facebook, LinkedIn, X, Slack and WhatsApp: Open Graph and Twitter card tags, image size and reachability, canonical match.

The checker fetches the page like a sharer does, reads the `og:image` tag, then downloads the image itself (up to 3 MB) and measures the file. It does not trust `og:image:width` and `og:image:height`; it reads the pixels. Three findings come out of that:

> **Check: Open Graph image is present.** Without og:image, links shared on Facebook, LinkedIn, Slack and WhatsApp appear as plain text and get far fewer clicks.
>
> 1. Add <meta property="og:image" content="https://…/share.jpg"> with a 1200×630 px image.
> 2. Use an absolute https URL that is publicly reachable.

> **Check: Open Graph image size.** Facebook and LinkedIn crop or shrink images smaller than 1200×630 px, so your preview looks blurry or cut off.
>
> 1. Export the share image at 1200×630 px (1.91:1) under 1 MB.

> **Check: Open Graph image can be fetched.** Facebook, LinkedIn and Slack download og:image themselves when someone shares the link. A relative URL, an http:// URL on an https page, or an error response means the card has no picture.
>
> 1. Point og:image at an absolute https:// URL that answers 200 with a JPEG, PNG or WebP.
> 2. Check that the image is not blocked by robots.txt, a login wall or a hotlink rule.

![The Open Graph image size finding opened: the title states the measured width and height of the share image against the 1200×630 recommendation, followed by why small images look blurry or cropped and the one-line fix to export at 1200×630](https://getreport.app/guides/img/generating-og-images-automatically/og-image-size.webp "The finding shows the measured pixel size, not the size the tag claims; a generator makes this number the same on every page.")

The size finding warns when the measured width is under 1200 px or the height under 630 px. The reachable finding warns before the download even starts if the URL is relative or `http://` on an HTTPS page, then again if the response is not a 200 with image bytes; the evidence line names the status and the reason. When everything passes, the evidence reads like `png 1200×630 px, 84 KB`, which is what you are aiming for on every page.

## Step by step

### 1. Design the template once

Sketch a 1200×630 canvas and place three things: the title, the logo, the background. Keep the title inside a safe area of about 100 px from each edge, because feeds crop the sides and chat apps crop the top and bottom; the exact crops per network are in [Open Graph images: size, safe area and cropping](https://getreport.app/guides/open-graph-images-size-safe-area). Check the design at 300 px wide, which is roughly how WhatsApp and a phone feed show it. If the title is unreadable at that size, the font is too small: 56–64 px for a short title, 48 px when it wraps to three lines, and cut anything longer with an ellipsis.

Decide what varies per page (title, maybe a category label and a date) and what does not (colours, logo, layout). Everything that does not vary is a candidate for a flat, well-compressed background.

### 2. Pick a generation approach

Four approaches cover almost every site. Choose by where your pages are built.

**Build-time route in the framework.** Next.js and Astro can render an image per page while building the site, so the image is a static file by the time anyone shares it. In Next.js, a route handler that reads `?title=` and returns an `ImageResponse` renders JSX to PNG with satori and needs no browser:

```tsx
// app/og/route.tsx  (Next.js App Router)
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const title = (searchParams.get('title') ?? 'Untitled').slice(0, 90);
  return new ImageResponse(
    (
      <div
        style={{
          width: 1200,
          height: 630,
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          padding: 64,
          background: '#F7F6F2',
          color: '#1a1b1e',
          fontFamily: 'sans-serif',
        }}
      >
        <div style={{ display: 'flex', fontSize: 28, fontWeight: 700 }}>example.com</div>
        <div style={{ display: 'flex', fontSize: title.length > 60 ? 54 : 64, fontWeight: 700, lineHeight: 1.1 }}>
          {title}
        </div>
        <div style={{ display: 'flex', fontSize: 24, color: '#5b6068' }}>example.com/blog</div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      headers: { 'cache-control': 'public, max-age=86400, immutable' },
    },
  );
}
```

Every `div` needs `display: flex` because satori supports a subset of CSS; forgetting it is the usual first error. The page then declares `<meta property="og:image" content="https://example.com/og?title=Your%20title">`. For a per-page file instead of a query string, Next.js also supports an `opengraph-image.tsx` next to the page, which adds the tags for you; that variant is in the safe-area guide.

**A small image service.** When the site is not built with a JavaScript framework (a PHP site, a static generator, a CMS you do not control), run a tiny service that turns HTML into a PNG with a real browser. It receives `/og?title=…`, renders a template page in Chromium and screenshots it:

```js
// og-service.mjs — node og-service.mjs, then reverse-proxy /og to port 3100
import { createServer } from 'node:http';
import { chromium } from 'playwright';

const browser = await chromium.launch();
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);

createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  if (url.pathname !== '/og') return res.writeHead(404).end();
  const title = esc((url.searchParams.get('title') ?? 'Untitled').slice(0, 90));
  const page = await browser.newPage({ viewport: { width: 1200, height: 630 } });
  await page.setContent(`<!doctype html><body style="margin:0;width:1200px;height:630px;display:flex;
    align-items:center;padding:64px;box-sizing:border-box;background:#F7F6F2;color:#1a1b1e;
    font:700 60px/1.1 system-ui"><div>${title}</div></body>`);
  const png = await page.screenshot({ type: 'png' });
  await page.close();
  res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400, immutable' });
  res.end(png);
}).listen(3100);
```

A browser render costs 200–500 ms and some memory, so put a cache in front (the CDN, or nginx's `proxy_cache`) and never let it run for every request. Escape the title, as above, or the query string becomes an injection point.

**An image CDN with text overlays.** Cloudinary, imgix and similar services compose text onto a base image through URL parameters. There is nothing to run: the URL names the base image, the size and the text layer, and the CDN renders and caches it. Encode the title for the URL and keep the base image at 1200×630 so no resizing happens. This is the quickest route when you already pay for one of these services; the URL syntax is documented by each vendor.

**WordPress.** The SEO plugin already writes `og:image` from the page's social image, the featured image or the site default, in that order. Two automatic options: set a 1200×630 featured image on every post (the plugin uses it) or point the tag at a generator with the plugin's filter. With Yoast:

```php
<?php
// wp-content/mu-plugins/og-image-generator.php
add_filter( 'wpseo_opengraph_image', function ( $image ) {
    if ( ! is_singular( 'post' ) || $image ) {
        return $image; // keep a hand-picked image when there is one
    }
    return 'https://og.example.com/og?title=' . rawurlencode( get_the_title() );
} );
```

`og.example.com` is the service from the previous option. Rank Math offers the same hook under `rank_math/opengraph/facebook/image`. Plugins that generate share images from titles exist too; whichever you use, check the output with the link preview checker rather than trusting the settings page.

### 3. Choose the format and keep the file small

PNG for flat designs with text (small and sharp), JPEG at 80 % for a photo background, WebP for either if you do not need the widest sharer support. The size finding asks for under 1 MB; aim well under that. A 1200×630 PNG with two colours and a title is 30–80 KB. A photo background as PNG is 800 KB or more, so use JPEG or flatten the photo into the template as JPEG and draw the text on top.

### 4. Cache it, and bust the cache on redesign

Both the route and the service above send `Cache-Control: public, max-age=86400, immutable`. With a CDN in front, one render serves everyone for a day. A year is fine too, as long as you change the URL when the template changes: add a version (`/og?v=2&title=…`) or a hash of the template to the path. Facebook, LinkedIn and Slack keep their own copy of the first image they saw for that URL; a new URL is the only reliable way to make them fetch again, apart from their debug tools.

That is how getReport's own images work. Each guide has `/guides/{slug}/og.png`, rendered with `ImageResponse` and revalidated once a day; each report has `/r/{id}/og.png`, rendered on request with the overall grade ring, the domain and the seven module scores. While a report is still running that image is cached for 30 s; once the report is finished it is served with `public, max-age=86400, immutable`, because a finished report never changes.

### 5. Keep a fallback

Some pages have no title worth showing (the 404 page, a tag archive), and a generator can fail (the service is down, a title contains characters the font lacks). Keep a static 1200×630 default image and use it whenever the generator would not produce something good. In the WordPress snippet above, a hand-picked image always wins; in a framework route, return the default when the title is empty.

### 6. Add the tags correctly

The image is only useful if the tags point at it:

```html
<meta property="og:image" content="https://example.com/og?title=Generating%20Open%20Graph%20images">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Generating Open Graph images, from example.com">
<meta name="twitter:card" content="summary_large_image">
```

Absolute URL, `https`, and a public path. The width and height tags let Facebook show the image on the first share instead of waiting for its crawler; getReport ignores them and measures the file, so they cannot hide a wrong size. The full tag set, and what Twitter cards add, are in [Link previews: Open Graph and Twitter cards that look right everywhere](https://getreport.app/guides/link-previews-open-graph).

## Platform notes

### WordPress

Featured images are the cheapest automation: if every post gets a 1200×630 featured image, Yoast and Rank Math do the rest. Make the theme's featured image size at least that big (Settings → Media only controls the default sizes, so check the theme's `add_image_size` calls or use the original). A page cache serves the tags, so purge it after changing the filter or the default image.

### Shopify

Shopify uses the product's first image or the theme's "Social sharing image" (Theme settings → Social media) for `og:image`. There is no per-page generator hook without an app; the practical route is a 1200×630 social sharing image for the store and product photos that are at least 1200 px wide.

### Static sites and custom builds

Generate at build time and commit nothing: Astro's `astro-og-canvas` style integrations and Next.js `opengraph-image` files write one PNG per page into the build output, served like any static file with a long cache lifetime. Add the version to the file name and the cache problem disappears.

## Verify

- Run the [link preview checker](https://getreport.app/tools/link-preview) on the home page, a post and a page without a hand-picked image. All three should show "Open Graph image is 1200×630" and "Open Graph image can be fetched".
- `curl -sI "https://example.com/og?title=Test" | grep -iE "content-type|cache-control"` prints `image/png` (or JPEG/WebP) and your cache header.
- Open the image URL in a private window: it must load without a login and without a cookie.
- Share one URL in Slack or WhatsApp. If the old image shows, the network cached it; use Facebook's Sharing Debugger or LinkedIn's Post Inspector to re-scrape, or bump the version in the URL.

## Common mistakes

- **The generator is on the staging domain.** `og:image` points at `staging.example.com`, which is behind a password. The reachable finding reports a 401 or 403; point the tag at the public host.
- **Relative URL in the tag.** `content="/og?title=…"` works in the browser and nowhere else. Sharers do not resolve relative paths; the finding says so before it even tries to download.
- **Title not escaped.** A title with `<` or `&` breaks the template or, in the browser-based service, executes as HTML. Escape it, and cap the length.
- **Rendering on every request.** A Chromium render per share is fine; one per bot hit, scraper and preview refresh is not. Put the CDN or `proxy_cache` in front and send a long `Cache-Control`.
- **Changing the template, keeping the URL.** Every network keeps showing the old design. Change the URL with a version parameter or a hash when the template changes.
- **Featured image at 800 px.** The SEO plugin dutifully uses it and the size finding warns. Upload at 1200×630 or larger, or let the generator take over for posts without one.
