DEV Community

AI Alleyway
AI Alleyway

Posted on

Generate your Open Graph images with React (Remotion), not a design tool

Every blog post, product page, and share link wants its own Open Graph image — the card that shows up when the URL is posted to X, LinkedIn, Slack, or iMessage. Hand-making those in Figma doesn't scale: the moment you have a few dozen pages, you're copy-pasting a template and re-exporting PNGs by hand.

I moved mine to code. One React component, one CLI command per card, and a size-optimization step. Here's the whole setup — including the two gotchas that cost me an afternoon.

Why Remotion?

Remotion is "React for videos" — but it renders still frames just as happily as video, and a 1200×630 OG card is just a single frame of a React component. That means your card is a normal component: props, flexbox, your real fonts and brand tokens, conditional layout. No design-tool round-trip, no drift between your site's styles and your cards.

The composition

Register a composition sized to the OG spec and drive everything from inputProps:

export const OgCard: React.FC<{
  headline: string;
  productName?: string;
  rating?: number;
}> = ({ headline, productName, rating }) => (
  <AbsoluteFill style={{ background: "linear-gradient(135deg,#0b0b0f,#1a1330)", color: "#fff", padding: 80, justifyContent: "space-between" }}>
    <h1 style={{ fontSize: 68, lineHeight: 1.05, fontFamily: "Outfit" }}>{headline}</h1>
    {productName && <div style={{ fontSize: 34, opacity: 0.8 }}>{productName}{rating ? ` · ${rating}/5` : ""}</div>}
  </AbsoluteFill>
);

// index.ts
<Composition id="OgCard" component={OgCard} width={1200} height={630}
  durationInFrames={60} fps={30} defaultProps={{ headline: "" }} />
Enter fullscreen mode Exit fullscreen mode

Rendering one card per page

renderStill writes a single PNG. Feed it the per-page props as JSON:

npx remotion still src/index.ts OgCard out/my-post.png \
  --props='{"headline":"The cheaper voice tool wasn'\''t on the pricing page","productName":"Acme TTS","rating":4}'
Enter fullscreen mode Exit fullscreen mode

Loop that over your content collection and you have a card per URL, regenerated on every build.

Don't ship the PNG — convert to WebP

A 1200×630 PNG out of a headless browser is big — mine came out around 770 KB. That's absurd for a social card. One conversion step drops it to ~17 KB with no visible loss:

# ImageMagick
magick out/my-post.png -quality 82 -define webp:method=6 out/my-post.webp
# or sharp: sharp(png).webp({ quality: 82 }).toFile(webp)
Enter fullscreen mode Exit fullscreen mode

Point your og:image / twitter:image at the WebP. ~45× smaller, same card.

Gotcha 1: selectComposition needs the SAME inputProps as renderStill

If you render programmatically (Node API) rather than the CLI, you call selectComposition() first, then renderStill(). Pass your inputProps to BOTH. Anything resolved at selection time — most importantly staticFile() references and anything derived from props in calculateMetadata — uses defaultProps if you only handed props to renderStill.

The symptom is baffling the first time: every card comes out byte-identical (the default props rendered), or an image staticFile('logo.png') silently resolves to the placeholder. It's not a caching bug — it's the selection step running on defaults.

const inputProps = { headline, productName, rating };
const comp = await selectComposition({ serveUrl, id: "OgCard", inputProps }); // ← here
await renderStill({ composition: comp, serveUrl, output, inputProps });        // ← and here
Enter fullscreen mode Exit fullscreen mode

Gotcha 2: renderStill({ frame }) requires durationInFrames > frame

If you register a still-only composition with durationInFrames: 1 and then ask for frame: 30 (say, to let an entrance animation settle), Remotion throws:

RangeError: Cannot use frame 30: Duration of composition is 1
Enter fullscreen mode Exit fullscreen mode

Give the composition enough frames for the frame you sample. I register OG/still compositions with durationInFrames: 60 (2s @ 30fps) and render whatever frame I want within that.

That's the whole pipeline

  • One React component = your card, in your real styles.
  • renderStill + a JSON props blob = one card per page, on every build.
  • A PNG→WebP step = ~45× smaller files.
  • Remember: same inputProps to selectComposition and renderStill, and durationInFrames bigger than the frame you sample.

Once it's wired, adding a new page's social card is zero manual work — it falls out of the build. Worth the afternoon.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

How does Remotion handle dynamic text rendering for Open Graph images, and do you think it's suitable for high-traffic sites? I'd love to swap ideas on optimizing this workflow.