sdxc

[ Building Remix apps ]

Draw social card images

Render an Open Graph image for every page inside a Cloudflare Worker, serve it at the page's own address with .png added, keep each card in R2 under a hash of its text, and advertise it with its size.

Last updated 2026-10-15

A link to your site shared in a chat or a timeline shows the image its page names in og:image. A hand-made image per page goes stale the day a title changes, and a single image for the whole site says nothing about the page. This guide draws a card for every blog post from the post's own title and description, inside the Worker that serves the post, so the card always matches the page.

@sdxc/raster is the canvas, @sdxc/font reads the TrueType fonts the text is set in, and @sdxc/png encodes the result, all in plain TypeScript with no WebAssembly. @sdxc/file-storage-r2 keeps every card drawn in an R2 bucket, @sdxc/crypto names each card by a hash of its text, and @sdxc/seo writes the tags that advertise it.

npm add @sdxc/crypto @sdxc/file-storage-r2 @sdxc/font @sdxc/http @sdxc/png \
    @sdxc/raster @sdxc/result @sdxc/seo remix

How a card is served

A post at /blog/hello has its card at /blog/hello.png, and the page advertises /blog/hello.png?v=<hash>, where the hash covers the card's text and the design's version. A request for the card goes through three stores before anything is drawn:

  1. The colo's edge cache, which answers a repeat request without running your code past the first lookup.

  2. The R2 bucket, which holds every card ever drawn under its hash, so a card is drawn once per text no matter how many colos ask for it.

  3. Drawing, which loads the fonts and the canvas only on this path, stores the result after the response, and answers with it.

Because the URL names the hash, a card served for a matching ?v= never changes and can be cached for a year. Edit the post and the page advertises a new hash, which platforms see as a new image.

Bundle the fonts

@sdxc/font reads TrueType outlines, the .ttf files of a static font with glyf outlines. Pick faces whose license allows embedding, such as the SIL Open Font License that Inter and most Google Fonts carry. Rename each file with a .bin extension, for example resources/fonts/inter-bold.ttf.bin: Wrangler and the Cloudflare Vite plugin bundle a .bin import as a data module, which the Worker receives as an ArrayBuffer. Declare that shape once for TypeScript:

env.d.ts
declare module "*.bin" {
	const bytes: ArrayBuffer;
	export default bytes;
}

Parse the fonts on the first card an isolate draws and keep them for the isolate's life. Parsing at module scope would run while Cloudflare validates the upload and again on every cold start, including the ones that only serve a page:

app/initializers/card-fonts.ts
import type { FontError } from "@sdxc/font";
import type { Result } from "@sdxc/result";

import { parseFont } from "@sdxc/font";
import { isFailure, success } from "@sdxc/result";

import type { CardFonts } from "~/app/services/social-cards/draw";

import INTER_BOLD from "~/resources/fonts/inter-bold.ttf.bin";
import INTER_REGULAR from "~/resources/fonts/inter-regular.ttf.bin";

let parsed: Result<CardFonts, FontError> | undefined;

export function cardFonts(): Result<CardFonts, FontError> {
	parsed ??= parseAll();
	return parsed;
}

function parseAll(): Result<CardFonts, FontError> {
	let regular = parseFont(INTER_REGULAR);
	if (isFailure(regular)) return regular;
	let bold = parseFont(INTER_BOLD);
	if (isFailure(bold)) return bold;
	return success({ regular: regular.data, bold: bold.data });
}

parseFont reads only the table headers; each glyph is decoded the first time it is drawn and cached on the Font. A failure is a FontError whose code is unsupported for a CFF-based .otf or a font collection, and missing-table or malformed for a damaged file, with table naming the table at fault.

Decide what the card says

The card's text comes from the post, and the hash covers exactly that text plus a template version you bump when the drawing changes:

app/services/social-cards/content.ts
import type { CryptoError } from "@sdxc/crypto";
import type { Result } from "@sdxc/result";

import { Hex, sha256 } from "@sdxc/crypto";
import { isFailure, success } from "@sdxc/result";

import type { Post } from "~/app/models/post";

export interface CardContent {
	title: string;
	description: string;
	site: string;
}

export const CARD_SIZE = { width: 1200, height: 630 } as const;

const CARD_TEMPLATE = "1";

export function cardContent(post: Post): CardContent {
	return { title: post.title, description: post.excerpt, site: "example.com" };
}

export async function cardVersion(
	content: CardContent,
): Promise<Result<string, CryptoError>> {
	let input = [
		CARD_TEMPLATE,
		content.title,
		content.description,
		content.site,
	].join("\u0000");
	let digest = await sha256(input);
	if (isFailure(digest)) return digest;
	return success(Hex.encode(digest.data).slice(0, 16));
}

export function cardKey(version: string): string {
	return `cards/${version}.png`;
}

Post is your own model. The NUL separator keeps "ab" + "c" and "a" + "bc" from hashing alike. Sixteen hex characters, 64 bits, tell a site's cards apart with room to spare, and keep the URL short. A new CARD_TEMPLATE changes every hash, so a redesign reaches every platform and every stored card is redrawn on its next request.

1200 by 630 is the size Open Graph consumers display a large card at.

Draw the card

The canvas fills shapes with antialiasing, blends gradients the way browsers do, and sets text in a font you pass it. text wraps at spaces to maxWidth, shrinks in 2 px steps toward minSize until the text fits maxLines, and ends the last line with … when it still does not, so a long title stays inside the card:

app/services/social-cards/draw.ts
import type { Font } from "@sdxc/font";

import {
	Canvas,
	color,
	linearGradient,
	Path,
	radialGradient,
	TRANSPARENT,
	withAlpha,
} from "@sdxc/raster";

import type { CardContent } from "~/app/services/social-cards/content";

import { CARD_SIZE } from "~/app/services/social-cards/content";

export interface CardFonts {
	regular: Font;
	bold: Font;
}

const INK = color("#f5f5f7");
const MUTED = color("#a1a1aa");
const ACCENT = color("oklch(0.62 0.19 275)");
const LEFT = 80;
const TEXT_WIDTH = 1040;

export async function drawCard(
	content: CardContent,
	fonts: CardFonts,
): Promise<Uint8Array> {
	let { width, height } = CARD_SIZE;
	let canvas = new Canvas(width, height);

	canvas.fillRect(
		0,
		0,
		width,
		height,
		linearGradient({
			from: [0, 0],
			to: [0, height],
			stops: [
				[0, color("#111827")],
				[1, color("#030712")],
			],
		}),
	);
	canvas.fillRect(
		0,
		0,
		width,
		height,
		radialGradient({
			center: [width, 0],
			radius: [720, 440],
			stops: [
				[0, withAlpha(ACCENT, 0.35)],
				[0.7, TRANSPARENT],
			],
		}),
	);

	let title = canvas.text(content.title, {
		font: fonts.bold,
		size: 72,
		minSize: 48,
		maxLines: 3,
		maxWidth: TEXT_WIDTH,
		tracking: -2,
		lineHeight: 1.1,
		x: LEFT,
		y: 200,
		paint: INK,
	});

	canvas.text(content.description, {
		font: fonts.regular,
		size: 30,
		maxLines: 2,
		maxWidth: TEXT_WIDTH,
		lineHeight: 1.4,
		x: LEFT,
		y: 200 + title.height + 40,
		paint: MUTED,
	});

	canvas.fillPath(Path.roundedRect(LEFT, 540, 16, 16, 8), ACCENT);
	canvas.text(content.site, {
		font: fonts.bold,
		size: 28,
		x: LEFT + 32,
		y: 558,
		paint: INK,
	});

	return canvas.toPNG();
}

y is the baseline of the first line, and text answers with the size it settled on, the lines it drew and their height, which is where the description starts. measureText answers the same metrics without drawing, for a layout that centres a block before drawing it.

Colours are parsed from hex, rgb() or oklch(), so the card can use the same tokens as your stylesheet; a malformed one throws a RangeError where it is written. toPNG() drops the alpha channel when every pixel is opaque, which makes the file a quarter smaller before compression.

Drawing a card takes tens of milliseconds of CPU, which is why each one is drawn once and stored.

Store cards in R2

Declare a bucket for the cards in your Worker's configuration:

wrangler.jsonc
{
	"r2_buckets": [{ "binding": "SOCIAL_CARDS", "bucket_name": "social-cards" }],
}

and open it as a FileStorage, the interface remix/file-storage defines:

app/models/social-cards.ts
import type { FileStorage } from "remix/file-storage";

import { R2FileStorage } from "@sdxc/file-storage-r2";
import { env } from "cloudflare:workers";

export function socialCards(): FileStorage {
	return new R2FileStorage(env.SOCIAL_CARDS);
}

get answers a native File with the name and type it was stored with, or null. A key is written once and never overwritten, since its name is its content, so a lifecycle rule on the bucket that deletes objects after 90 days keeps it to the cards still being shared. A card the rule removed is drawn again the next time someone asks for it.

Serve the card at the page's address

Add the card next to the page in the route table. A pattern variable stops at a ., so :slug.png matches /blog/hello.png and leaves /blog/hello to the page:

routes/web.ts
import { get, route } from "remix/routes";

export default route({
	post: get("/blog/:slug"),
	postCard: get("/blog/:slug.png"),
});

The action checks the edge cache, then the bucket, then draws. It imports the drawing code and the fonts only when it is about to draw, so the requests R2 or the edge answer never load them:

app/http/controllers/post-card.ts
import { notFound } from "@sdxc/http/response/html";
import { isFailure } from "@sdxc/result";
import { waitUntil } from "cloudflare:workers";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import Posts from "~/app/models/post";
import { socialCards } from "~/app/models/social-cards";
import {
	cardContent,
	cardKey,
	cardVersion,
} from "~/app/services/social-cards/content";
import routes from "~/routes/web";

const IMMUTABLE = "public, max-age=31536000, immutable";
const REVALIDATE = "public, max-age=3600";

export default createAction(routes.postCard, async (ctx) => {
	let edge = edgeCache();
	let cached = await edge.match(ctx.request);
	if (cached) return cached;

	let respond = (card: File, cacheControl: string) => {
		let response = new Response(card.stream(), {
			headers: {
				"Content-Type": "image/png",
				"Content-Length": String(card.size),
				"Cache-Control": cacheControl,
			},
		});
		waitUntil(edge.put(ctx.request, response.clone()));
		return response;
	};

	let storage = socialCards();
	let version = ctx.url.searchParams.get("v");
	if (version !== null && /^[0-9a-f]{16}$/.test(version)) {
		let stored = await storage.get(cardKey(version));
		if (stored) return respond(stored, IMMUTABLE);
	}

	let { slug } = s.parse(s.object({ slug: s.string() }), ctx.params);
	let post = await Posts.find(ctx.db, slug);
	if (post === null) return notFound("Not Found");

	let content = cardContent(post);
	let hash = await cardVersion(content);
	if (isFailure(hash)) {
		ctx.log.fail(hash.error, { slug });
		return new Response("The card could not be named", { status: 500 });
	}

	let key = cardKey(hash.data);
	let cacheControl = version === hash.data ? IMMUTABLE : REVALIDATE;
	let stored = await storage.get(key);
	if (stored) return respond(stored, cacheControl);

	let [{ cardFonts }, { drawCard }] = await Promise.all([
		import("~/app/initializers/card-fonts"),
		import("~/app/services/social-cards/draw"),
	]);
	let fonts = cardFonts();
	if (isFailure(fonts)) {
		ctx.log.fail(fonts.error, { slug, table: fonts.error.table ?? null });
		return new Response("The card's fonts could not be read", { status: 500 });
	}

	let bytes = await drawCard(content, fonts.data);
	let card = new File([bytes.slice()], `${hash.data}.png`, { type: "image/png" });
	waitUntil(storage.set(key, card));
	return respond(card, cacheControl);
});

function edgeCache(): Cache {
	return (caches as unknown as { default: Cache }).default;
}

A v that names a stored card is answered from R2 without reading the post at all: the hash already says which card it is. A request without v, or with one from before the post changed, renders from the post as it is now and is cached for an hour, so a person who pastes the bare .png address still sees an up-to-date card.

caches.default is the colo's Workers cache, keyed by the full URL, query included. The edgeCache helper names its type for projects whose tsconfig.json also loads the DOM library, whose CacheStorage declares no default. Both writes, to the edge and to R2, run in waitUntil, after the response has gone out.

The page names its card in its head. Compute the same version the card route computes, and pass the absolute URL with its size to the Seo component from SEO, sitemaps and robots.txt:

app/services/social-cards/meta.ts
import type { Seo } from "@sdxc/seo";

import { isFailure } from "@sdxc/result";

import type { Post } from "~/app/models/post";

import { seo } from "~/app/initializers/seo";
import {
	CARD_SIZE,
	cardContent,
	cardVersion,
} from "~/app/services/social-cards/content";
import routes from "~/routes/web";

export async function postCardMeta(post: Post): Promise<Seo.OpenGraph> {
	let version = await cardVersion(cardContent(post));
	if (isFailure(version)) return { type: "article" };

	let path = routes.postCard.href({ slug: post.slug });
	return {
		type: "article",
		image: seo.absolute(`${path}?v=${version.data}`),
		imageAlt: post.title,
		imageWidth: CARD_SIZE.width,
		imageHeight: CARD_SIZE.height,
	};
}

In the post's action, og: await postCardMeta(post) goes into the Seo.Props it renders. Seo writes og:image and twitter:image with the URL, og:image:alt, and og:image:width and og:image:height, which let a platform lay out the preview on the first share, before it has fetched the image. Since the URL changes with the text, a platform that caches images by URL fetches the new card after an edit with no purge on your side.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.