satori

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

5posts · 30d
Nov 40 posts
  1. 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 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.

  2. DEV · · @dziulatex

    Next.js OG images without the @vercel/og limits next/og (the ImageResponse API, built on @vercel/og) is the default way to make Open Graph images in Next.js, and for a title on a gradient it is excellent. It is not a browser, though: it converts JSX to SVG with Satori and rasterises it with Resvg. When your card needs CSS grid, your real stylesheet, woff2 fonts or more than 500 KB of assets, the fix is not to fight Satori — it is to render the card with real Chrome and put the resulting URL in generateMetadata. This guide lists the limits precisely, shows when they bite, and gives a drop-in replacement that works on the Node.js and Edge runtimes. The limits, from the Next.js docs The ImageResponse reference states them plainly: - Only flexbox and a subset of CSS. "Advanced layouts (e.g. display: grid) will not work." The full list of supported properties is in Satori's README. - 500 KB bundle limit, counting your JSX, CSS, fonts, images and other assets together. - Fonts: ttf, otf and woff only — no woff2, which is what most font services and next/font hand you. - Default output is 1200×630 PNG, which is the right size for Open Graph. In practice people hit these as: a two-column card that needs grid; a brand font that only ships as woff2 (convert it, or it silently falls back); two weights of a CJK font that blow past 500 KB on their own; a logo as SVG with filters Resvg does not support; and the general cost of re-implementing a design in inline styles because the site's CSS is not available. A note if you read Satori's own README: recent standalone Satori releases added CSS grid and WOFF2, but the @vercel/og that Next.js bundles (1.0.x in Next 16, built on an older Satori) does not have them — the Next.js limits above are the ones that apply to next/og. None of this is a bug. Satori trades completeness for running anywhere in milliseconds. If your card fits inside it, keep it. Option A: keep next/og, but stay inside the lines If the limits are only fonts and size, fix those and stay with ImageResponse: // app/blog/[slug]/opengraph-image.tsx import { ImageResponse } from 'next/og'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; // ttf/otf parse fastest; subset the font to Latin to stay far below 500 KB. const inter = readFile(join(process.cwd(), 'assets/Inter-Bold-latin.ttf')); export default async function Image({ params }: { params: Promise<{ slug: string }> }) { const post = await getPost((await params).slug); return new ImageResponse( ( <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between', width: '100%', height: '100%', padding: 72, background: '#0b0a09', color: '#fff' }}> <div style={{ fontSize: 64, fontWeight: 700, lineHeight: 1.1 }}>{post.title}</div> <div style={{ fontSize: 28, opacity: 0.7 }}>yourblog.com</div> </div> ), { ...size, fonts: [{ name: 'Inter', data: await inter, weight: 700, style: 'normal' }] }, ); } Subsetting fonts (for example with pyftsubset from fonttools) is the single most effective fix for the 500 KB limit. Option B: real Chrome, as a URL in generateMetadata When you need grid, your stylesheet, woff2, emoji fonts or complex scripts, render the card from HTML and CSS in a real browser. Running Chrome inside a serverless function is possible (a trimmed Chromium build such as @sparticuz/chromium with puppeteer-core), but cold starts of several seconds and memory limits make it fragile — and a link-preview crawler will not wait. The lighter option is a signed image URL: you compute it in generateMetadata (no network call, no API key on the page), and the image service renders it on the first request and serves the cached file afterwards. Below is Shareglint's URL format (disclosure: I build Shareglint) with its built-in blog-og template — swap in your own HTML template's slug to use your own design. Web Crypto makes the same code run on Node.js and Edge: // lib/og.ts const enc = (v: string) => encodeURIComponent(v).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()); export async function ogImageUrl(template: string, fields: Record<string, string>) { const path = `/i/${process.env.OG_ACCOUNT_ID}/${template}.png`; const query = Object.keys(fields).sort().map((k) => `${enc(k)}=${enc(fields[k])}`).join('&'); const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(process.env.OG_SIGNING_SECRET!), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${path}?${query}`)); const s = Array.from(new Uint8Array(mac), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 32); return `https://shareglint.com${path}?${query}&s=${s}`; } // app/blog/[slug]/page.tsx import type { Metadata } from 'next'; import { ogImageUrl } from '@/lib/og'; export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { const post = await getPost((await params).slug); const image = await ogImageUrl('blog-og', { title: post.title, author: post.author, site: 'yourblog.com' }); return { title: post.title, openGraph: { images: [{ url: image, width: 1200, height: 630, alt: post.title }] }, twitter: { card: 'summary_large_image', images: [image] }, }; } encodeURIComponent leaves ! ' ( ) * unescaped, which RFC 3986 does not — the replace fixes that so the signature matches. We checked this function against the test vector in the API docs. OG_SIGNING_SECRET has no NEXT_PUBLIC_ prefix, so Next.js never inlines it into client bundles; only the signature ends up in the HTML. Two Next.js details to know: - File conventions win. If a route segment has an opengraph-image.tsx or .png, Next.js uses it instead of openGraph.images from generateMetadata — the generateMetadata docs say file-based metadata "has the higher priority and will override" both. Delete the file when you switch. - Absolute URLs. A signed URL is already absolute. If you keep any relative image paths, set metadataBase in the root layout or Next.js cannot build the full URL. Option C: render at build time for static exports With output: 'export' there is no server at request time. A signed URL still works — it is a string computed during the build. If you would rather ship files, call the API from a build script and store the returned URLs: // scripts/og-images.ts — run before `next build` const res = await fetch('https://shareglint.com/v1/images?wait=true', { method: 'POST', headers: { Authorization: `Bearer ${process.env.OG_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ template: 'blog-og', modifications: { title: post.title, site: 'yourblog.com' } }), }); const { url } = await res.json(); // same fields again = same file, no extra render Which to choose | You need | Use | |---|---| | Title, logo, gradient; fonts under 500 KB | next/og | | CSS grid, your own stylesheet, woff2, complex scripts | Real Chrome — signed URL (B) | | No server at all (output: 'export') | Signed URL computed at build, or a build script (C) | | One generic card for every page | A static opengraph-image.png | Whichever you pick, verify the result where it is seen: paste a production URL into LinkedIn's Post Inspector and Facebook's Sharing Debugger — localhost URLs cannot be fetched by either. FAQ What are the limits of next/og ImageResponse? According to the Next.js docs: only flexbox and a subset of CSS (no display: grid), a 500KB bundle limit including JSX, CSS, fonts and images, and only ttf, otf and woff fonts. It renders with Satori and Resvg, not a browser. Can I use Tailwind or my existing React components in an OG image? Only partly with next/og: Satori accepts inline styles and a tw prop, but not your stylesheet, CSS grid or arbitrary components. A renderer that runs real Chrome takes ordinary HTML and CSS, so the card can share your design system. Does opengraph-image.tsx override generateMetadata? Yes. Next.js gives file-based metadata higher priority than config-based metadata, so an opengraph-image file in a route segment replaces the openGraph.images you return from generateMetadata. Do I need the Edge runtime for OG images? No. ImageResponse works on both the Node.js and Edge runtimes, and a signed image URL is just a string you compute — Web Crypto makes the same code work on either. --- Originally published on Shareglint.

  3. DEV · · @jreznot

    Better JetBrains Marketplace screenshots for every plugin The media section is the first thing people see on a JetBrains Marketplace plugin page. It's also where most plugins look their worst: full-screen IDE captures shrunk to carousel size, unreadable 13px text, desktop wallpaper around the window, and every screenshot a different size. The good-looking ones are usually composed slides. A short headline sits on a tinted background next to a cropped, zoomed-in IDE window, and one thing on screen is highlighted. Making those by hand in a design tool takes a while, and you redo all of it whenever the UI changes. So I built mpshots. It's a small Node CLI. You give it your raw IDE screenshots and a YAML file, and it renders a consistent set of Marketplace slides. !Raw capture of the Air plugin's account picker Raw window capture… !Composed slide: "Bring your own agent and account" …and the slide mpshots makes from it. What it does You describe each slide in a shots.yaml: output: width: 1920 theme: dark brand: name: Air accent: "#3574F0" defaults: uiScale: 2 # Retina capture slides: - id: accounts layout: hero headline: Bring your own agent and account body: Switch between JetBrains AI, a ChatGPT account or your own API key screenshot: src: screenshots/accounts.png crop: [574, 996, 1645, 720] # x, y, w, h in source pixels highlight: [1561, 1256, 637, 413] # draws an accent outline with a glow Then run mpshots build. You get one PNG per slide, plus an index.html that shows them at about the size the Marketplace carousel uses. There are four layouts: - text-left: text on the left, IDE window on the right running off the edge of the slide. - text-right: the mirror image of text-left. - hero: centred headline with a wide window below. - window: the window on its own, no text. On any slide you can add highlights, which outline something on screen. You can also add an overlay, a second window floating on top, such as a browser preview next to the IDE. Every slide uses the same logical 1920×1080 canvas, and output.width sets the pixel size: 1920, 2560 or 3840. Your set always comes out with one aspect ratio, which the Marketplace guidelines ask for. Here's the full set for the Air plugin, all from one spec: !Agent sessions slide !Terminal agent slide !Diff review slide !Inline prompt slide The checks Laying out slides wasn't the hard part. The hard part was knowing whether a slide will still be readable once it's shrunk into the carousel. Before rendering anything, mpshots validate checks: - IDE text size: it estimates how big the IDE's 13px UI font ends up after your crop and zoom (13 × uiScale × zoom), and warns if it lands under 16px. - Upscaling: it warns if the screenshot has to be blown up more than 1.05×, because the result will look soft. - Copy length: headlines over 60 characters, body text over 240. - Hard errors: missing files, and crops or highlights that fall outside the image. --strict turns warnings into failures, so you can run it in CI. Building this taught me one thing: a 1920×1080 capture at 100% IDE zoom can't be both readable and sharp on a slide. To make the text big enough, you have to scale the capture up, and then it goes blurry. The fix is at capture time. Zoom the IDE to 125–150% (View › Appearance › Zoom IDE), or capture on a Retina display, and set uiScale to match. The Air screenshots above are 2800×1800 Retina window captures. They pass both checks without being scaled up. Finding crop coordinates Nobody wants to guess pixel rectangles. mpshots grid draws a labelled pixel grid over a capture so you can read off [x, y, w, h] directly: mpshots grid screenshots/accounts.png # → screenshots/accounts.grid.png How it renders There's no headless browser. Layout is done by satori, which turns flexbox-style elements into SVG. resvg turns that SVG into pixels, and sharp handles crops and resampling. Each screenshot is resampled once, with Lanczos, straight to the final output size. A slide takes about two to three seconds. It runs anywhere Node 18 does, CI included. Try it It installs straight from GitHub: npm i -g github:jreznot/mpshots#v0.1.0 mpshots init # creates shots.yaml and screenshots/ mpshots build The repo includes two complete examples: a set built from generated mock IDE screens, and the Air set above from real captures. Clone it and run mpshots build in examples/air to see the whole pipeline. Next I'd like to remove the manual capture step. An IDE action could save a clean window capture at a known zoom and write the crop rectangles for you. Issues and PRs are welcome: jreznot/mpshots.

  4. satori ⭐ 14k Satori converts HTML and CSS into SVG images using JSX, for developers who need to generate images from markup. 🔗 vercel/satori #opensource t.co

    8 views
  5. X · · @5yuim

    vercelがありえん初歩的なバグ起してて横転 satori#844

    38 views

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

3
PackageVersionDownloads · 30dTrend · 30d
———
v1.1.0
≥26K
satoriv0.9.1
v0.9.1
≥7.3M

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days
vercel/satori mentions in the last 30 days