Per-link Open Graph images for shareable countdowns with Satori and @vercel/og

2026-11-03来源 dev.to WebDevEnglish

When someone pastes a countdown link into WhatsApp, Slack or iMessage, the preview card decides whether anyone clicks it. A generic logo gets ignored. A card that says "Our wedding · 47 days to go" gets taps.

That means rendering an image for every link, on demand. Here's how I do it with Satori, the JSX-to-SVG engine behind @vercel/og, plus the parts that tripped me up: picking the right number, fonts and emoji, caching, and the fact that link crawlers never run your JavaScript.

The rendering code below was run in Node 20 with satori@0.63 and @resvg/resvg-js@2.6. The Vercel route is the same code wrapped in ImageResponse.

How the pieces fit

An Open Graph image is just a URL in a meta tag:

<meta property="og:image" content="https://example.com/api/og?id=abc123" /> <meta name="twitter:card" content="summary_large_image" /> 

When a crawler sees the tag, it fetches that URL and expects a PNG or JPEG, ideally 1200×630. So we need an endpoint that:

  1. looks up the countdown (title, date, time zone),
  2. works out the number to show,
  3. lays out a card and returns a PNG.

Satori handles step 3. It takes a React-like element tree and returns SVG, using a subset of CSS (flexbox only, no grid). A rasteriser such as resvg turns that SVG into a PNG. @vercel/og bundles both behind one ImageResponse class.

Step 1: the number has to be right

The image is rendered on a server, usually in UTC. If you compute "days to go" with Date.now() on that server, a card shared at 23:30 in Warsaw can be a day off. The fix is the same one I used in earlier posts: compare calendar dates in the countdown's own time zone, not timestamps.

export function daysUntil(dateISO, timeZone, now = new Date()) { // 'en-CA' formats as YYYY-MM-DD const today = new Intl.DateTimeFormat('en-CA', { timeZone }).format(now); const toUTC = (s) => { const [y, m, d] = s.split('-').map(Number); return Date.UTC(y, m - 1, d); }; return Math.round((toUTC(dateISO) - toUTC(today)) / 86_400_000); } 

A quick check at 2026-12-24T23:30:00Z, with the target date 2026-12-26:

daysUntil('2026-12-26', 'Europe/Warsaw', now); // 1 (it's already the 25th in Warsaw) daysUntil('2026-12-26', 'America/New_York', now); // 2 (still the 24th there) 

Store the time zone with the countdown when it's created. The server can't guess it from the crawler's request, because the crawler is a bot in a data centre, not your user.

Step 2: lay out the card

Satori accepts JSX if you have a JSX transform. In plain Node I pass the object form, which is what JSX compiles to anyway:

import satori from 'satori'; import { Resvg } from '@resvg/resvg-js'; import { readFileSync } from 'node:fs'; const font = readFileSync('./fonts/Inter-Bold.ttf'); export function clean(s, max = 60) { return String(s ?? '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, max) || 'Untitled'; } export async function renderOg({ title, date, tz = 'UTC', now }) { const n = daysUntil(date, tz, now); const big = n === 0 ? 'Today' : String(Math.abs(n)); const sub = n === 0 ? '' : n > 0 ? (n === 1 ? 'day to go' : 'days to go') : (n === -1 ? 'day since' : 'days since'); const svg = await satori( { type: 'div', props: { style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#0f172a', color: 'white', padding: 60, }, children: [ { type: 'div', props: { style: { fontSize: 56, textAlign: 'center' }, children: clean(title) } }, { type: 'div', props: { style: { fontSize: 200, lineHeight: 1 }, children: big } }, { type: 'div', props: { style: { fontSize: 44, color: '#94a3b8' }, children: sub } }, ], }, }, { width: 1200, height: 630, fonts: [{ name: 'Inter', data: font, weight: 700, style: 'normal' }] }, ); return new Resvg(svg).render().asPng(); } 

Some things I learned from this:

  • Every div with more than one child needs display: 'flex'. Satori throws an error rather than guessing.
  • You must supply font bytes. There are no system fonts. Load a TTF/OTF or WOFF; WOFF2 isn't supported. Ship only the weights you use, because each one adds to cold-start time.
  • Count-up and countdown share the card. A negative number becomes "days since", so the same endpoint works for "days since I quit smoking".

Step 3: user input is text, not HTML

Titles come from users. With Satori they are only ever text nodes, so a title like Christmas <script> is drawn literally as those characters. There's no HTML to inject. You still want to:

  • cap the length (clean() cuts at 60 characters) so a 5,000-character title can't make the layout explode or slow the render;
  • look the title up by ID instead of trusting ?title= from the query string. Otherwise anyone can mint a card on your domain that says anything. If you must take parameters, sign them with an HMAC and reject bad signatures.

Step 4: emoji and non-Latin scripts

People love emoji in countdown titles ("Japan trip 🇯🇵"). My first render showed an empty box (tofu) where the 🎄 should have been, because the font has no emoji glyphs. There are two fixes:

  • In @vercel/og, set emoji: 'twemoji' (or another supported set) and it fetches the SVG for each emoji.
  • In raw Satori, implement loadAdditionalAsset(code, segment). Return an emoji image as a data URL, or font bytes for scripts like Japanese or Arabic that your main font lacks.

Test with a title in each script your users actually write in. Missing glyphs fail silently.

Step 5: the Vercel route

On Vercel, @vercel/og wraps all of this in a Response. For a framework-free function (my app is a Vite SPA), api/og.jsx looks roughly like this:

import { ImageResponse } from '@vercel/og'; export const config = { runtime: 'edge' }; export default async function handler(req) { const id = new URL(req.url).searchParams.get('id'); const c = await getCountdown(id); // your DB lookup if (!c) return new Response('Not found', { status: 404 }); const n = daysUntil(c.date, c.timeZone); return new ImageResponse(<Card title={clean(c.title)} n={n} />, { width: 1200, height: 630, emoji: 'twemoji', headers: { 'Cache-Control': 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400' }, }); } 

<Card> is the same tree as above, written as JSX. In Next.js App Router you'd use ImageResponse from next/og in an opengraph-image.tsx file instead.

Step 6: caching a number that changes every day

A countdown card goes stale at midnight. A few options:

  • Short CDN cache (s-maxage=3600). It's cheap and at most an hour stale around midnight.
  • Put the date in the URL: /api/og?id=abc&d=2026-12-24. Each day gets a new URL that can be cached forever. This also beats the platforms' own caches; Facebook and LinkedIn cache previews by URL for days, so a new URL is the only reliable way to refresh them.

I prefer the second option: generate the meta tag with today's date in the countdown's time zone.

The SPA trap: crawlers don't run your JavaScript

This is the most common reason previews don't show up. Setting og:image with react-helmet or document.head does nothing for previews. WhatsApp, Slack, Discord and iMessage fetch the raw HTML and read the meta tags without running a line of JS.

So the HTML for /c/abc123 must contain the tags when it leaves the server. In a Vite SPA, either:

  • add a rewrite for /c/:id to a small function that reads index.html, injects the per-countdown <title>, og:title, og:description and og:image, and returns it; or
  • move those routes to a framework with server rendering.

Then check what bots see with curl -A "Slackbot" https://your.site/c/abc123 | grep og:. If the tags aren't in that output, no preview will show.

Checklist

  • 1200×630 PNG, summary_large_image, absolute https:// URLs
  • Number computed from calendar dates in the countdown's stored time zone
  • Font bytes loaded, emoji handled, titles length-capped
  • Data looked up by ID, not taken raw from the query string
  • A URL that changes daily, or a short CDN cache
  • Meta tags present in the server HTML, checked with curl

I wrote this while thinking about link previews for CountMyDays, a free, ad-free app for countdowns and "days since" trackers. If you've found a cleverer way to keep preview cards fresh, tell me in the comments.

原文:https://www.a8q9.cn/en/article/per-link-open-graph-images-for-shareable-countdowns-with-satori-and-vercel-og-38c834.html

相关文章