The picture goflag asks for
@goflag/og draws the share card a multilingual site puts in front of every link, and packs the favicon.ico no Next convention emits. The core renders nothing.
Two of this catalogue's rules had no remedy to point at.
og.image.missing fired 24 times on one site and
og.image.alt 46 times on another, and the fix for
both is "either an asset or a route you have to write" — which is how a rule
becomes permanent debt rather than a thing anybody acts on.
@goflag/og is that route, written once.
pnpm add -D @goflag/ogNode >=22. react is a peer dependency and next an optional one; the runtime
depends on nothing.
The core renders nothing
That is the design, not an omission. defineOg hands back a JSX tree, a size and
an alt. @goflag/og/next passes the tree to ImageResponse, which already
embeds satori — so nothing installs a second renderer, no native binary joins
your dependency tree, and a card can be asserted about in a plain unit test with
no framework build anywhere near it.
// lib/og.tsx
import { defineOg } from "@goflag/og";
export const og = defineOg({
name: "Example",
footer: "example.com",
mark: (side) => <Logo size={side} />,
tokens: { bg: "#200b03", fg: "#dfcab2", dim: "#9e7f69", border: "#4b2915", accent: "#ab4500" },
fit: { steps: [{ upTo: 32, fontSize: 72 }], smallest: 48 },
});| Field | What it is |
|---|---|
tokens | Five colours. Not eight — one accent and never three is a constraint, not taste |
name | The wordmark beside the mark |
mark | Your logo, as a function of its side in pixels |
footer | Bottom left. The host, on both sites this came from |
dots | Bottom right, for a site whose taxonomy is a colour rather than decoration |
fit | The title degression. Required, and it has no default — see below |
mark takes its side as an argument so one drawing serves the card and every
icon size. That is not a convenience: on the site you are reading, the flag was
drawn three times — in the card, in apple-icon.tsx and in icon.svg — and two
of the three used colours the stylesheet does not declare anywhere.
fit is required, and shipping a default would be shipping a defect
Satori cannot measure text before rendering. There is no honest fitText to
write, so the card counts glyphs and picks a step, with lineClamp and
textWrap: balance catching what a guess cannot.
The boundaries are yours, and they have to be measured. List every title a card on your site can carry, count the graphemes, and put the boundaries in the gaps between the clusters.
Two sites did this and got tables that share no number. One is calibrated on rule ids with sentence-long summaries; the other has fifteen page titles grouped at 5–31 and 51–56 glyphs. Reusing the first table on the second put a boundary exactly on its longest real title — so two locales would have rendered a size apart over one character of difference, which is worse than not degrading at all. A default here would be that defect, shipped to everyone.
The routes
// app/[locale]/opengraph-image.tsx
import { ogImage } from "@goflag/og/next";
import { og } from "@/lib/og";
const image = ogImage(og, async ({ params }) => {
const { locale } = await params;
const t = translator(locale);
const title = t("hero.title");
return { title, subtitle: t("hero.lead"), alt: t("meta.ogAlt", { title }) };
});
export const generateImageMetadata = image.generateImageMetadata;
export default image.render;The loader returns what goes on the card, and is called once per export — so the copy is read in one place and the picture and its description cannot disagree.
| Field | On the card |
|---|---|
title | The one large body. Its size comes from fit |
subtitle | Under it, cut at 160 glyphs — measured in glyphs, like the title |
label | The pill beside the wordmark: a section, a severity, a kind. Optional |
alt | The sentence og:image:alt carries. Yours, translated, from your data |
generateImageMetadata, never the alt export. Next's static alt is one
constant string per file, and the description of a generated card cannot be a
constant: the card carries the page's title as pixels, so the sentence describing
it is translated and derived from data. That is the difference between an
og:image a screen reader can announce and one it cannot.
Do not call getTranslations in there.
Next runs generateImageMetadata the way it runs generateStaticParams — at build time, with no
request — and a request-scoped i18n config reaches for headers(), so the build fails outright
rather than degrading, with Route /[locale]/… used headers() inside generateStaticParams.
Build a translator straight from your message JSON instead. It is four lines, and this package deliberately does not ship them: depending on one i18n library to save four lines would cost every consumer more than it saved.
ogIcon(og, 180) gives icon.tsx and apple-icon.tsx the same mark and the
same palette as the card. ogCatchAllRoute(og, …) covers the one case Next has
no convention for at all: it refuses to place a metadata image under a
catch-all segment, so a [...slug] route needs a force-static route handler
instead. That is the special case, not the normal path — an ordinary [slug]
segment takes opengraph-image.tsx directly.
Your tokens will drift unless something compares them
Satori resolves no CSS variable and does not speak oklch(), so a theme written
in OKLCH has to be restated as sRGB somewhere. That duplication is forced. Going
unnoticed when the theme moves is not — and it is what actually happens. On one
of these sites all four transcribed greys were wrong, by a hue step and by
sixteen levels: invisible, which is exactly why the comment claiming they were
the theme's colours was never going to be enough.
So oklchPalette is here, and there are two honest ways to use it. In a build
script, read the stylesheet and there is nothing left to transcribe:
const theme = oklchPalette(readFileSync("src/app/globals.css", "utf8"), { scope: ".dark" });In a module a bundler picks up — where reading a file by relative path is a bet on the working directory — keep the literals and let a test hold them against the sheet.
Name the scope. A theme declares the same property once per scheme, and without a scope the first declaration in the file wins: the light one, on a site whose card is dark.
What the package gives back
| Call | Returns |
|---|---|
defineOg(definition) | an Og — card(), icon(), and the definition it was given |
og.card({ title, subtitle, label, alt }) | { element, size, contentType, alt } — renders nothing |
og.icon(side) | { element, size, contentType }, the mark on the surface |
ogImage(og, loader) | { generateImageMetadata, render } for one route file |
ogIcon(og, side) | { size, contentType, render } for icon.tsx |
ogCatchAllRoute(og, { entries, slugOf, card }) | { generateStaticParams, GET } |
fitTitle(title, fit) | { fontSize, lineClamp } — the degression, on its own |
countGraphemes(value) | length in glyphs rather than UTF-16 units |
truncateGraphemes(value, max) | cut to max glyphs, ellipsis included in the count |
oklchPalette(css, { scope }) | every --name: oklch(…) in scope, as sRGB hex |
readOklch(css, property, { scope }) | one declaration, or a throw naming what is absent |
oklchToHex([l, c, h]) / oklchToRgb | the conversion on its own, for a value not in a stylesheet |
OG_SIZE / OG_CONTENT_TYPE | 1200 × 630 and image/png, the two constants the card uses |
OG_SIZE and OG_CONTENT_TYPE are worth taking rather than restating. A route
that names its own card — the catch-all, which cannot use the file convention —
has to declare the image's shape to @goflag/next, and taking the same two
constants the renderer draws with is how the declaration and the picture are kept
from disagreeing. See
what a route says about its card.
fitTitle and the two grapheme helpers are exported because the degression is
the part you have to measure. A test that pins where your boundaries fall — that
no real title of yours sits on one — is four lines, and it is the only thing
standing between a copy change and two locales rendering a size apart.
buildIco, writeIcons, writeIco and fingerprint are the icon half:
the favicon no convention emits.
What this is not
- Not a gallery of templates. One card, driven by tokens.
- Not a renderer. No satori and no
@resvg/resvg-jsin the dependency tree, so no native binary and no Alpine friction. - Not a rasteriser, including for the
.ico— see the icons. The core packs buffers; your site produces them, with thesharpit already has. - Not RTL-capable. Satori does not do RTL. This is a hard boundary rather than a missing feature.