Per-link Open Graph images for shareable countdowns with Satori and @vercel/og
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
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 [email protected] and @resvg/[email protected]. 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:
- looks up the countdown (title, date, time zone),
- works out the number to show,
- 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
divwith more than one child needsdisplay: '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, setemoji: '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/:idto a small function that readsindex.html, injects the per-countdown<title>,og:title,og:descriptionandog: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, absolutehttps://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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.