A social card is a build artifact, not a runtime service. The failure mode of the alternatives is drift: a screenshot service that captures the page after a deploy captures whatever it finds that day, a remote image URL can rot or be hot-swapped into something the page no longer endorses, and a shared static PNG means every route claims the same card regardless of what the page actually says. A card that is generated at build time from the same registry data that renders the page cannot drift, because there is only one source of the title and dek that appear in both places.
On hraness.com, every shareable page gets its own generated card, including this one, whose opengraph-image route renders the lesson title and dek from the same registry entry that produced the page. The card is an opengraph-image module colocated with the route, built by a shared renderer with embedded fonts, and it never touches the network or the filesystem at request time. The whole system is one renderer, one registry, and one route convention.
one card per route
The rule that makes the system honest is the one the package enforces: one generated card per public page, not one shared PNG across routes. socialCardPath computes the route for a page (/ maps to /opengraph-image, and a nested page maps to <pagePath>/opengraph-image) and socialCardImages wraps that into the openGraph.images and twitter.images blocks at the fixed LARGE_SOCIAL_IMAGE size:
export const LARGE_SOCIAL_IMAGE = {
height: 630,
width: 1200,
} as const;
The Twitter card is summary_large_image and the Open Graph image is declared at 1200×630 with an alt and an absolute URL. Because the image URL is derived from the page path, two routes can never accidentally share a card, and because the image is built from the same registry record as the page, a route can never have a card that describes different copy than the page itself.
This page’s own card is the demonstration: /reference/web-delivery/deterministic-social-images/opengraph-image renders this lesson’s title and dek because lessons.ts is the only place those strings live. The metadata, the card, and the article all read the same record; there is no copy that could drift.
the renderer
createSocialImageResponse in the shared web-discovery package produces a next/og ImageResponse. The layout is inline and deliberately fixed: a header row with the eyebrow (or the domain) and an optional mark, a middle block with the title at 64px (or 52px when the title is longer than 58 characters) and a 26px muted description, and a footer with the domain in the accent color. There are no remote assets and no runtime filesystem reads. The fonts are Nebula Sans faces loaded from @hraness/design-kit/fonts/nebula-sans/social as in-bundle OTF payloads, so ImageResponse never reaches a remote font loader and Vercel output tracing never depends on repository files.
The default theme is the plain-site palette (accent #2457A6, background #FFFFFF, foreground #171717, muted #666666) and a product overrides it by passing an explicit theme. The mark is a closed set: socialImageMarks currently defines one value, splitSquare, rendered as an inline SVG, and any other value is a RangeError before rendering. The card is deterministic in the strong sense: same input, same pixels, no ambient state.
That determinism is what “zero runtime” means here. There is no image service, no headless browser, no screenshot queue. The card is computed when the route module is evaluated (at build for a statically generated page) and the response is the PNG. The renderer has no I/O to fail, no cache to invalidate, and no external state that could make Tuesday’s card differ from Monday’s. The only inputs are the four copy fields and the theme, and all of them come from the same record that produced the page.
the glyph and color contract
Two validations are what keep the cards from silently breaking in production. The first is color: every theme field must match ^#[0-9A-F]{6}$ (case-insensitive), and anything else throws a RangeError naming the field and the bad value. A three-digit hex, a named color, or an rgb() function is a build error, not a visual regression discovered after a share.
The second is glyph coverage. Before ImageResponse is ever constructed, every code point in the title, description, domain, and eyebrow is checked against the cmap tables of both embedded Nebula Sans faces. The check parses the font binaries directly (reading the cmap table, accepting format-4 and format-12 subtables, and resolving each code point to a glyph index) and throws if a character is absent from either face:
throw new RangeError(
`${label} contains ${JSON.stringify(character)} (${notation}), which is not covered by the embedded Nebula Sans social fonts.`,
);
That is the difference between a card that fails at build time with the exact code point in the error and a card that renders a missing-glyph box in a link preview on someone’s phone. Copy is normalized first (\r\n collapses to \n, tabs become spaces) so the check runs against what will actually be rendered.
the route module
A page’s card is an opengraph-image.tsx module beside its page.tsx. For a reference lesson it is about twenty lines:
export const alt = "reference · hraness";
export const contentType = socialImageContentType;
export const size = socialImageSize;
export const dynamicParams = false;
export default async function Image({ params }: ReferenceLessonImageProps) {
const { category: categorySlug, lesson: lessonSlug } = await params;
const category = findReferenceCategory(categorySlug);
const lesson = findReferenceLesson(categorySlug, lessonSlug);
if (category === undefined || lesson === undefined) notFound();
return createHranessSocialImage({
description: lesson.dek,
eyebrow: `reference · ${category.title}`,
title: lesson.title,
});
}
Three details carry the design. dynamicParams = false plus a notFound() for unknown params means the route only ever renders cards for registered lessons: there is no path that produces a card for a page that does not exist. createHranessSocialImage is a thin wrapper that supplies the domain constant hraness.com and delegates to the shared renderer, so product code cannot accidentally render a card for the wrong domain. And the fields (eyebrow, title, description) are read from the same lessons.ts record that generateMetadata used for the page, so the card, the meta tags, and the rendered article cannot disagree.
The registry-read is also what makes the cards composable. A route that wants a different eyebrow or a product accent passes different fields to the same renderer; it does not reimplement the layout. The shared surface is the layout, fonts, and validation; the per-page surface is which record feeds it. That is the difference between a component and a copy: a copy would drift.
what determinism buys
The operational property is that a card can be verified before it is ever served. A repository check renders every registered public page’s opengraph-image module, confirms the copy matches the registry, and confirms the route census (every indexable page has exactly one card) as part of the build. There is no deployed service to probe, no cache to bust, and no external dependency to fail at request time.
There is a subtler benefit: the card route is not negotiated. isNonNegotiatedPath treats every */opengraph-image path as excluded, so a card request can never accidentally return Markdown, and a .md request for a card path cannot produce one. The card and the document are different representations of the same data with different content types, and the routing knows the difference. An agent that asks for Accept: text/markdown on the page gets Markdown; a crawler that fetches the og:image URL gets a PNG, and neither path can produce the other.
That is also what makes it cheap to keep honest. A card that requires a screenshot farm or a remote font would have a failure surface proportional to the number of pages times the number of ways the service can degrade. A card that is a function from registry data to a PNG has a failure surface of “does the build pass,” and the glyph check turns even the copy mistakes into build errors. The card for this page exists because the build computed it; it will still be right on the day the lesson is edited, because the build will compute it again from the same record. If you implement this pattern, the load-bearing decisions are the validations: the glyph coverage check and the color parse are what turn “the card looked wrong in the preview” into a failing build.
And it is why the metadata contract from the earlier lesson and this card are the same discipline applied twice: the og:image points at a URL whose contents are derived from the same record as the og:title beside it. A card that could say something different from the page is a card that can lie; one computed from the same registry cannot.
sources
- web-discovery:
createSocialImageResponse,LARGE_SOCIAL_IMAGE,plainSocialImageTheme, thecmapglyph check, andsocialImageMarksinsrc/social-image.tsx. - design-kit: the embedded Nebula Sans social-font payloads the renderer loads.
- The monorepo behind this site (projects):
app/social-card.tsandapp/reference/[category]/[lesson]/opengraph-image.tsx. - Next.js: ImageResponse: the
next/ogAPI the renderer wraps.