The social card for Notifio is a PNG at /og-image.png, and it is produced by taking a Playwright screenshot of a string of HTML at build time.
That is the second answer I have given to this problem. Another app in the same family renders its card with next/og, from the same logo SVG, font files and palette the site itself uses, which I wrote up in our link preview cards are drawn by code, from the same three files as the site. I still think that is the right call there. Here I deliberately did the opposite, and the reasons are all about where the render happens rather than about which renderer draws nicer text.
/**
* It is a build-time script, not a route. `next/og` would put a satori render on
* the request path and cannot read the woff2 files the app already ships, and an
* OG card that changes once a quarter has no business being computed per
* request. Playwright comes from the desktop app workspace, which already
* depends on it and already has a Chromium downloaded, so the server package
* takes on no new dependency for a script that runs by hand.
*
* Run from server/: node scripts/generate-og-image.mjs
*/
Borrowing a browser from the workspace next door
Notifio is a repository with two halves: a marketing site in server/, and the Electron desktop app in app/. The app drives a real browser, so it already depends on Playwright and, because it ships Chromium inside the packaged app, it already has one downloaded on disk. I wrote about that bundling in shipping Playwright's Chromium inside a packaged Electron app.
So the script reaches sideways rather than adding a dependency:
const HERE = dirname(fileURLToPath(import.meta.url));
const APP = join(HERE, "../../app");
// Set before playwright is imported: it reads the browsers path at module load.
process.env.PLAYWRIGHT_BROWSERS_PATH ??= join(APP, "playwright-browsers");
const { chromium } = await import(join(APP, "node_modules/playwright/index.mjs"));
Three details in four lines, and two of them are things I got wrong first.
The dynamic import is load-bearing. PLAYWRIGHT_BROWSERS_PATH is read when the Playwright module initialises, not when you call launch(). A static import { chromium } from "playwright" is hoisted above the assignment, so the environment variable would be set after the value it is meant to influence has already been read, and the launch would go looking in the default cache. await import() puts the import back in statement order. That only works at the top level because this is a .mjs with top-level await available, which is one of the better arguments for writing repo scripts as ESM.
If you have ever been confused about why setting an env var in your script had no effect on a library, this is usually the shape of it: the library read it during module initialisation and your assignment ran after the import.
??= rather than =. An explicitly provided PLAYWRIGHT_BROWSERS_PATH still wins, which matters on a machine where the app workspace has not been installed.
The channel has to be named. This one cost me a genuinely confusing ten minutes:
// `channel: "chromium"` picks the full browser the app workspace ships. The
// default would reach for chrome-headless-shell, which that workspace has no
// reason to download and therefore does not have.
const browser = await chromium.launch({ channel: "chromium" });
Recent Playwright versions default chromium.launch() to chrome-headless-shell, a separate and smaller download. The desktop app needs a full browser and only installs that, so the default asks for an executable that is not in the directory even though the directory clearly contains a Chromium. The error reads like a corrupt install rather than a wrong channel.
The fonts are the actual reason this is not next/og
The card uses three typefaces, and they arrive by two different routes. Geist Mono comes out of node_modules and gets inlined:
/** Inlined so Chromium never has to resolve a file:// URL for them. */
const monoFont = (weight, file) => {
const path = join(SERVER, "node_modules/geist/dist/fonts/geist-mono", file);
const b64 = readFileSync(path).toString("base64");
return `@font-face{font-family:'Geist Mono';font-weight:${weight};font-style:normal;src:url(data:font/woff2;base64,${b64}) format('woff2');}`;
};
page.setContent() gives the page an about:blank-ish origin, from which relative and file:// font URLs are a fight you do not need to have. Base64 in a data URL is bigger and completely reliable, and the file is thrown away thirty milliseconds later anyway.
The other two come from Google Fonts with an ordinary <link>, because the site loads them the same way through next/font/google. That is why the screenshot waits on the network:
await page.setContent(html, { waitUntil: "networkidle" });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: OUT, type: "png" });
That middle line is the one I would put on a poster. networkidle tells you the requests finished. It does not tell you the font has been applied and the text re-laid-out, so a screenshot taken immediately after can catch the fallback face, with the wrong metrics and the wrong weight, in a 1200x630 image that you then ship to every social platform on the internet. It fails in the ugliest possible way: it looks fine locally when the font is warm in the HTTP cache, and wrong on a cold machine.
document.fonts.ready is a promise that resolves when font loading and layout have settled. It is two words and it is the difference between a card that is correct and a card that is correct on your laptop.
The 2x asset with a 1x declaration
The render is 1200x630 logical pixels at double density:
const page = await browser.newPage({
viewport: { width: WIDTH, height: HEIGHT },
deviceScaleFactor: 2,
});
The file on disk is therefore 2400x1260 and about 215KB. The metadata still declares the logical size:
images: [{ url: "/og-image.png", width: 1200, height: 630, alt: "Notifio" }],
That is not an inconsistency, it is the point. og:image:width and og:image:height describe the aspect and layout the consumer should reserve; the bitmap is allowed to be denser than that. A link preview in Slack or iMessage on a retina screen renders the card at a good physical size, and a 1x PNG of text at 74px looks soft in exactly the place where the card is doing its job.
You can check the numbers yourself without downloading anything much:
# The PNG header carries the real dimensions in bytes 16..24.
python3 -c "import struct;d=open('og-image.png','rb').read();print(struct.unpack('>II',d[16:24]))"
# (2400, 1260)
The honest cost
The next/og version of this in my other app cannot drift from the site, because it literally imports the site's three inputs. This one is weaker, and the weakness is in the CSS:
/* The same 40px grid the homepage CTA section lays over its background. */
.grid{
position:absolute;inset:0;opacity:0.03;
background-image:
linear-gradient(rgba(255,255,255,1) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,1) 1px, transparent 1px);
background-size:40px 40px;
}
"The same 40px grid" is a claim in a comment, not a shared constant. The fonts genuinely cannot drift, because the script reads the same woff2 files the site serves, and the logo cannot drift because it reads public/icon-light.svg. But the palette and the grid are a copy, and if I restyle the homepage this file will not notice.
I decided that was acceptable for a card that changes about once a quarter, and I would decide differently for anything rendered per-request or per-page. It is a real trade and I would rather write it down than pretend the script is more principled than it is.
The pills at the bottom of the card have the same problem in a more dangerous form, since they quote a price:
<div class="pill">£20 one-time</div>
<div class="pill">macOS & Windows</div>
<div class="pill">No subscription</div>
Everywhere else on the site, that number is derived from the constant Stripe charges, so the pricing page and the structured data cannot disagree with the invoice. In this file it is typed out. A build-time script that reads LICENSE_PRICE_PENCE would have been three lines, and the only reason it does not is that the script predates my caring about it. That is a genuine bug waiting for a price change, and writing this paragraph is how I noticed.
The rule I took away
The question was never "satori or Chromium". It was "what is this code allowed to depend on". A request-time renderer can only use what is in the serverless bundle, which rules out reading font binaries off disk and rules out launching a browser. A build-time script can use anything on the machine, including a browser that a sibling workspace downloaded for a completely different reason.
Deciding where the render happens first, and then picking the tool, got me to a simpler answer than starting from the tool. And it is why the same problem has two different answers in two of my apps without either of them being wrong.
Top comments (0)