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: "" }} />
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}'
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)
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
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
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
inputPropstoselectCompositionandrenderStill, anddurationInFramesbigger 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)
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.