Uploading a share image for every page doesn't scale: every new page, rename and locale needs another one. Here's how to generate them instead, from each page's own data (title, locale, section) in the brand's design. It's written so you can also hand it to a coding agent: read it top to bottom, then follow "Implementation".
This site works exactly this way: every page's share image, including this note's, is generated from its title at build time.
Why
- No uploads to manage. Editors never create, crop or translate an image. A new page (or a new locale of a page) gets a share image for free.
- Always in sync. Rename a page and its image updates on the next build.
- Locales stop multiplying work. 20 locales × 300 pages is 6,000 images to upload by hand; generated, it's one template.
- On brand by construction. The design lives in code, built from the same logo, colours, gradients and fonts as the site.
Is it free? What does it depend on?
Yes: completely free, and nothing runs on a third-party service. Images are rendered on your own build machine (or your own server) by two open-source libraries:
| Package | What it does | License | Maintainer |
|---|---|---|---|
| satori | Lays out HTML/CSS-like elements (flexbox) and outputs an SVG | MPL-2.0 | Vercel (open source) |
| sharp | Converts the SVG to PNG | Apache-2.0 | libvips community |
| Font files (e.g. @fontsource/*, or the brand's own) | Text rendering | Usually OFL / the brand's license | — |
Notes for a company review:
- No API keys, accounts, hosted services or per-image fees. No network calls at render time (fonts are read from disk).
satoriis the same engine behindnext/og(ImageResponse). Next.js ships it built in, so a Next.js project may need no new dependency at all.@resvg/resvg-js(MPL-2.0) is a common alternative tosharpfor the SVG → PNG step if the project prefers it; either works.- Hosting: with static generation the PNGs are plain files on the CDN. With on-demand rendering (Next.js route, serverless function) each image costs one function invocation the first time, then it's cached.
How it works
page data (title, locale…) ──► template (JSX-like tree) ──► satori ──► SVG ──► sharp ──► PNG
▲
fonts (TTF/OTF/WOFF), logo (SVG)- Every page knows its share-image URL from its own path, e.g.
/de/pricing/→/og/de/pricing.png. - A route renders that PNG from the page's data: at build time (static sites) or on request with long caching (server-rendered sites).
- The page's
<head>pointsog:image/twitter:imageat it.
Design rules (satori's CSS subset)
satori supports a subset of CSS. Design within it and the output will match the preview:
- Layout is flexbox only. Any element with more than one child needs
display: 'flex'. No CSS grid, no floats. - Backgrounds: solid colours,
linear-gradient(…),radial-gradient(…),repeating-*-gradient, multiple layers,backgroundSize. This is how to do brand gradients and grid textures. - Images: inline
<svg>(best for logos: crisp, no fetch), or<img>with adata:URI / absolute URL. Prefer the logo as an SVG string. - Fonts: TTF, OTF or WOFF (not WOFF2). Load each weight you use.
- Text:
fontSize,fontWeight,lineHeight,letterSpacing,textAlign,textOverflow: 'ellipsis'withlineClamp. Wrapping works. - Borders, radius, box-shadow, opacity, transforms work. Filters and blend modes are limited.
- Size: 1200 × 630 (works on every platform). Keep important content away from the outer ~60px; some apps crop.
Locales
- One image per locale and page. Put the locale in the path (
/og/fr/about.png) and render the localized title. - Fonts must cover the script. A Latin font renders nothing for Japanese, Arabic, Hindi, etc. Load script-specific fonts and pick them by locale, e.g. Noto Sans JP for
ja, Noto Sans Arabic forar, Noto Sans Devanagari forhi. satori falls back through thefontslist in order, so you can pass the brand font first and a Noto font after it. - Right-to-left (ar, he, fa): set
direction: 'rtl'on the text container and mirror the layout (logo on the right). - Long titles: German and Finnish titles run long. Step the font size down by character count, and clamp to three lines.
- Emoji: satori can render emoji via
loadAdditionalAsset; or strip them. - Per-locale strings on the image (e.g. a tagline) come from the same translation source as the page, never hard-coded.
Removing uploads from the CMS (Sanity)
- Remove the share-image field from the SEO object and from site settings in the schema.
- Migrate existing documents: unset those fields (keep the assets in the media library until you're sure).
- Stop querying the image; query the title (and locale) instead.
- Keep an optional SEO title field if editors want a shorter title on the image than on the page:
coalesce(seo.title, title).
Implementation
1. The renderer (framework-agnostic)
// lib/og.ts — server only
import { readFile } from 'node:fs/promises';
import satori from 'satori';
import sharp from 'sharp';
export const OG_SIZE = { width: 1200, height: 630 } as const;
type Node = { type: string; props: Record<string, unknown> };
const h = (type: string, props: Record<string, unknown>, ...children: (Node | string)[]): Node => ({
type,
props: { ...props, children: children.length === 1 ? children[0] : children },
});
// Load once per process. Use the brand's fonts; add script fonts per locale.
const fonts = Promise.all([
readFile('assets/fonts/Brand-Bold.woff'),
readFile('assets/fonts/Brand-Black.woff'),
]).then(([bold, black]) => [
{ name: 'Brand', data: bold, weight: 700 as const, style: 'normal' as const },
{ name: 'Brand', data: black, weight: 900 as const, style: 'normal' as const },
]);
const LOGO_SVG_PATH = 'M…'; // the brand mark's <path d>, or inline the whole <svg>
function card({ title, eyebrow, rtl }: { title: string; eyebrow?: string; rtl?: boolean }) {
const size = title.length > 60 ? 56 : title.length > 32 ? 72 : 88;
return h(
'div',
{
style: {
...OG_SIZE,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: 80,
direction: rtl ? 'rtl' : 'ltr',
color: '#fff',
fontFamily: 'Brand',
// Brand gradient + a subtle grid texture, layered.
backgroundImage: [
'linear-gradient(to right, rgba(255,255,255,.06) 1px, transparent 1px)',
'linear-gradient(to bottom, rgba(255,255,255,.06) 1px, transparent 1px)',
'linear-gradient(135deg, #1b1f3b 0%, #3a2ca8 55%, #ff5a4e 100%)',
].join(', '),
backgroundSize: '48px 48px, 48px 48px, 100% 100%',
},
},
h(
'svg',
{ width: 56, height: 56, viewBox: '0 0 32 32' },
h('path', { d: LOGO_SVG_PATH, fill: '#fff' }),
),
h(
'div',
{ style: { display: 'flex', flexDirection: 'column', gap: 20 } },
...(eyebrow ? [h('div', { style: { fontSize: 28, fontWeight: 700, opacity: 0.8 } }, eyebrow)] : []),
h('div', { style: { fontSize: size, fontWeight: 900, lineHeight: 1.05, letterSpacing: '-0.02em' } }, title),
),
);
}
export async function renderOgImage(options: Parameters<typeof card>[0]) {
const svg = await satori(card(options) as never, { ...OG_SIZE, fonts: await fonts });
return sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toBuffer();
}
/** The share image URL for a page path: /de/pricing/ → /og/de/pricing.png */
export const ogImagePath = (pathname: string) => `/og/${pathname.replace(/^\/|\/$/g, '') || 'index'}.png`;Keep OG_SIZE and ogImagePath in a separate tiny module if page code imports them, so pages don't bundle satori and sharp.
2a. Astro (static): one PNG per page at build time
// src/pages/og/[...slug].png.ts
import type { APIRoute, GetStaticPaths } from 'astro';
import { renderOgImage } from '../../lib/og';
export const prerender = true;
export const getStaticPaths = (async () => {
const pages = await fetchAllPages(); // [{ path: 'de/pricing', title: 'Preise', locale: 'de' }, …]
return pages.map((page) => ({ params: { slug: page.path || 'index' }, props: page }));
}) satisfies GetStaticPaths;
export const GET: APIRoute = async ({ props }) =>
new Response(new Uint8Array(await renderOgImage({ title: props.title, rtl: ['ar', 'he'].includes(props.locale) })), {
headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable' },
});2b. Next.js (App Router): built in, no new dependency
next/og wraps satori and handles PNG output and caching. Either a file convention per route segment:
// app/[locale]/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function Image({ params }: { params: Promise<{ locale: string; slug: string }> }) {
const { locale, slug } = await params;
const page = await getPage(slug, locale);
return new ImageResponse(<Card title={page.title} rtl={locale === 'ar'} />, { ...size, fonts: await loadFonts(locale) });
}…which Next.js links in <head> automatically. Use generateStaticParams on the segment to pre-render them at build; otherwise they render on first request and are cached. (Card is the same tree as card() above, written as JSX with style={{…}}.)
2c. Any other stack
Call renderOgImage() from a build script that writes public/og/<path>.png for every page, or from a server route that returns the PNG with a long Cache-Control.
3. Point the pages at it
<meta property="og:image" content="https://example.com/og/de/pricing.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Preise | Example" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://example.com/og/de/pricing.png" />URLs must be absolute. Pages that aren't indexed (404, previews) can reuse the home page's image.
4. Check it
- Test a long title, a short one, and every script you support.
- Add an end-to-end test that fetches a couple of images and asserts
200andcontent-type: image/png.
Testing locally (no deploy needed)
There are two different things to check, and only the second needs anything special.
The image itself: just open it
The dev server renders share images on request, the same way the build does. Open the image URL in a browser while dev is running:
http://localhost:4321/og/index.png # home
http://localhost:4321/og/about.png # /about/
http://localhost:4321/og/legals/privacy-policy.png # /legals/privacy-policy/The pattern is /og/<page path>.png (index for the home page). Change the template or a title in the CMS, refresh, and the image updates. This covers almost all design work. (Next.js: open http://localhost:3000/<route>/opengraph-image.)
A quick check from the terminal:
curl -sI http://localhost:4321/og/about.png | grep -i content-type # image/pngThe link card: needs a public URL
How LinkedIn, X, Slack, WhatsApp or iMessage show the link is decided by their servers fetching your page, and they can't reach localhost. Two things to know:
- The page's
og:imagetag is an absolute URL on the live domain (https://www.example.com/og/about.png), because platforms require absolute URLs. A preview tool reading your local page would therefore show the deployed image, not your local change. - To test the real card before deploying, expose the dev server through a free tunnel. Cloudflare's needs no account:
brew install cloudflared # once
cloudflared tunnel --url http://localhost:4321 # prints https://<random>.trycloudflare.comPaste a page URL from that tunnel (not the image URL) into opengraph.xyz (opens in a new tab), LinkedIn's Post Inspector (opens in a new tab) or Facebook's Sharing Debugger (opens in a new tab). Stop the tunnel (Ctrl+C) when done; the URL dies with it.
For the card to show your local image too, the absolute URL in og:image has to use the tunnel's address during that test. Build it from the request's own origin in dev, or from a SITE_URL override in .env, rather than hard-coding the production domain.
Caching
Platforms cache cards aggressively, sometimes for days. After changing an image: re-scrape in the platform's debugger, or bust the cache by adding a version to the image URL (/og/about.png?v=2).
Prompt for a coding agent
Read this guide (pasted below). Replace this project's uploaded Open Graph images with generated ones following it. Use our logo at<path>, our fonts at<path>, and our brand colours/gradients from<file>. Generate one image per page and per locale; pick fonts by locale so every script renders; mirror the layout for RTL. Remove the share-image upload fields from the CMS schema, write a migration that unsets them, and update the SEO tags. Show me three sample images (short title, long German title, Arabic) before wiring it everywhere.