Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 6 min read

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:

  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.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.