[ 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:
The colo's edge cache, which answers a repeat request without running your code past the first lookup.
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.
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:
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:
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:
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:
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:
{
"r2_buckets": [{ "binding": "SOCIAL_CARDS", "bucket_name": "social-cards" }],
}
and open it as a FileStorage, the interface remix/file-storage defines:
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:
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:
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.
Advertise the card
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:
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
SEO, sitemaps and robots.txt — the rest of the head tags and the
seoinstance used here.Draw charts as PNG images — the same canvas for data, where SVG cannot go.
Set text as SVG paths — font metrics and glyph outlines without a canvas.
Cache on Cloudflare Workers — tagging and purging the edge cache.
@sdxc/raster— every drawing call and text option.