Postales de una fábrica de OG en Satori
Cómo armamos un sistema de tarjetas OG por ruta con Satori + resvg — gatos por mood deterministas, CTAs rotativas y el bug de enteros con signo que nos cazó.
Cuando compartes un post de Catsoup Media, la tarjeta de previsualización que ves — panel morado, disco azafrán, gato aleatorizado por mood, CTA rotativo — la renderiza una pequeña fábrica que escribimos en una tarde. Este post es el recorrido por la fábrica, el bug que causamos, y algunas opiniones sobre Satori en producción.
La forma del asunto
Cada tarjeta OG en catsoupmedia.com se genera en tiempo de build mediante un endpoint de Astro. El endpoint declara toda su superficie de rutas en getStaticPaths y sirve un cuerpo PNG desde un handler GET — el patrón de endpoint de archivo estático de Astro para salidas que no son .astro.
// src/pages/og/[...slug].png.ts
export async function getStaticPaths() {
const manifest = await getOgManifest();
return manifest.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
export const GET: APIRoute = async ({ props }) => {
const node = renderTemplateFor(props.entry);
const png = await renderOg(node);
return new Response(png, {
headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=31536000, immutable" },
});
};
getOgManifest() enumera todo lo que necesita una tarjeta — la página de inicio, dos páginas de archivo, cada post del blog, cada caso de estudio, tanto en EN como en ES. El endpoint entrega la entrada que coincide a una de cuatro plantillas y devuelve el PNG.
Las cuatro plantillas: default (archivos + fallback), article (posts del blog), case-study (entradas del portafolio) y home (la tarjeta cinematográfica de la página de inicio). Todas viven como archivos .tsx en src/lib/og/templates/, cada una exportando una función que devuelve un árbol de nodos con forma de JSX. Satori hace el renderizado — no hay ningún runtime de React involucrado.
El sistema entero son ~600 líneas de TypeScript. Genera docenas de tarjetas en cada build, y la única pieza de infraestructura que necesita es
resvg-js.
Satori en un párrafo
Satori toma un árbol de nodos con forma de JSX (sin React, solo la forma) más una lista de buffers de fuentes y devuelve un SVG. Vercel lo describe como el motor detrás de su librería @vercel/og. Shu Ding, el autor, presenta a Satori como “el primer motor de renderizado CSS-a-SVG que no depende de un entorno de navegador”. Le pasas ese SVG a @resvg/resvg-js y por el otro extremo sale un PNG.
Vercel publicó números concretos comparando el resultado contra Chrome headless: el time-to-first-byte de cold-start en P99 bajó de 4.96s a 0.99s, y el bundle de deploy de ~50MB (Chromium + Puppeteer) a ~500KB — aproximadamente 5× más rápido y 100× más liviano.

// src/lib/og/render.ts
import satori from "satori";
import { Resvg } from "@resvg/resvg-js";
export async function renderOg(node: ReactNode): Promise<Uint8Array> {
const fonts = await ogFontConfig();
const svg = await satori(node, { width: 1200, height: 630, fonts });
return new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }).render().asPng();
}
Qué parsea Satori en realidad (una corrección que nos debemos)
La primera versión de este post afirmaba que Satori “pasa nuestras mascotas SVG correctamente, gradientes y rellenos por clase de CSS incluidos”. Eso es incorrecto, y el README lo dice explícitamente: Satori no soporta etiquetas <style> ni recursos externos vía <link> o <script>. También te restringe a un subconjunto flexbox de CSS — el mismo subconjunto implementado por Yoga — y solo acepta archivos de fuente TTF, OTF y WOFF (nada de WOFF2). El soporte para variable fonts no se menciona en absoluto en el README; trátalo como no soportado hasta que se demuestre lo contrario.
Entonces, ¿cómo se renderizan nuestros gatos por mood? Están embebidos como URLs de datos <img src="data:image/svg+xml;base64,...">. Cuando Satori encuentra un <img>, inserta la fuente como un elemento <image> en el SVG de salida; el propio bloque <defs><style> del SVG sobrevive ese viaje de ida y vuelta y lo maneja resvg cuando rasteriza el compuesto. El crédito de que nuestros rellenos por clase de CSS funcionen le pertenece a resvg-js, no a Satori.
Gatos por mood, hasheados por slug
La marca tiene catorce SVGs de mascota basados en moods que viven en public/assets/mascot/. Nombres como happy, loving, intrigued, super-big-surprise. Queríamos que cada post del blog y cada caso de estudio tuviera su propio mood — pero el mismo mood cada vez, para que las comparticiones en redes sociales sean estables.
Solución: un hash determinista de slug a mood, sobre un subconjunto curado.
// src/lib/og/assets.ts
const MOODS = [
"big-surprise", "happy", "intrigued", "loving",
"super-big-surprise", "surprise", "Suspicious",
] as const;
function hashSlug(slug: string): number {
let h = 5381;
for (let i = 0; i < slug.length; i++) h = ((h << 5) + h) ^ slug.charCodeAt(i);
return h >>> 0;
}
export function pickMoodForSlug(slug: string) {
const names = [...loadMoodSvgs().keys()];
const name = names[hashSlug(slug) % names.length];
return { name, dataUrl: moods.get(name)! };
}
El conjunto curado es la mitad de los moods disponibles — descartamos bored, caught, hypnotized y algunos más porque se leían como ambiguos o negativos en la miniatura de una tarjeta. “Suspicious” se quedó porque también funciona como “intensamente concentrado”.
La función de hash es djb2, publicada originalmente por Dan Bernstein en comp.lang.c en 1991. La semilla elegida (5381) y el multiplicador (33) se escogieron porque producían “menos colisiones y mejor avalancha” que las alternativas — la magia del 33 nunca se ha explicado adecuadamente. Suficientemente bueno para pasar de slug a índice.
CTAs que rotan sin ser aleatorios
Cada tarjeta tiene un botón tipo pastilla al pie — “Read more →”, “The whole story →”, “Ver el armado →”. No los queríamos estáticos (aburrido) y no los queríamos verdaderamente aleatorios (cambian en cada refresh, rompen las previsualizaciones al compartir). El mismo truco que con los moods: hashear el slug, sacar el módulo con el largo del pool.
const CTAS = ["Read more", "Read on", "Keep reading", /* ...20 total */] as const;
export function pickCtaForSlug(slug: string, locale: "en" | "es" = "en"): string {
const pool = locale === "es" ? CTAS_ES : CTAS;
const idx = ((hashSlug(slug) ^ 0xa5a5a5a5) >>> 0) % pool.length;
return pool[idx];
}
El XOR con 0xa5a5a5a5 rota el hash para que la elección de CTA quede decorrelacionada de la elección de mood. Dos posts con el mismo mood igual reciben CTAs distintos.
Hay cuatro pools en total: blog EN, blog ES, caso de estudio EN, caso de estudio ES. Cada uno tiene 20 entradas, curadas a mano. Los pools del blog se inclinan hacia lo relacionado con la lectura (“Take a bite”, “Keep reading”). Los pools de casos de estudio se inclinan hacia lo relacionado con proyectos (“See the build”, “Mira cómo lo cocinamos”). El idioma y la plantilla juntos eligen cuál pool.
El bug que causamos
Cuando lanzamos por primera vez el selector de CTA, ~30% de las tarjetas se renderizaban sin pastilla de CTA. Caja azul marino vacía, sin texto. La función se veía correcta.
El arreglo tomó diez minutos de console.log para encontrarlo:

Los operadores bit a bit de JavaScript están especificados normativamente para convertir sus operandos a enteros de 32 bits con signo. La referencia de “Fixed-width number conversion” de MDN lo dice sin rodeos: “Bitwise operators… convert the operands to 32-bit integers… using two’s complement encoding”. La página de MDN para el operador XOR bit a bit lo reafirma: “For numbers, the operator returns a 32-bit integer”. La demostración más clara es el operador NOT bit a bit: ~5 === -6. El algoritmo subyacente está en ECMA-262 §6.1.6.1.16 (NumberBitwiseOp), que llama a §7.1.6 (ToInt32) — ambos producen resultados con signo.
Como 0xa5a5a5a5 tiene su bit más alto en 1, el resultado del XOR para cualquier slug cuyo hash caiga en el patrón de bits equivocado es negativo. CTAS[-19] es undefined. Entonces pickCtaForSlug devuelve undefined y el guard {cta && (...)} de la plantilla omite silenciosamente la pastilla.
El arreglo fuerza el resultado del XOR de vuelta a sin signo antes del módulo, usando el único operador bit a bit sin signo de JavaScript — el idiom >>> 0, el “zero-fill right shift by zero”. Las tarjetas volvieron a renderizar CTAs.
La lección, una vez más, es que en JavaScript
^es una operación con signo y a%no le importa que el resultado se haya vuelto negativo. Cualquier cosa que hashees hacia un índice de array, pásala primero por>>> 0.
Una nota sobre la elección del PRNG en otras partes del sistema: usamos Mulberry32 para cualquier trabajo aleatorio que no sea de hash. El gist del autor dice que pasa las “13 statistical tests” de gjrand sobre 4GB de salida — suficientemente sólido para aleatoriedad cosmética. El mismo gist también señala que no es equidistribuido y se salta alrededor de un tercio de los valores uint32, lo cual está bien para nuestro caso de uso (elegir de pools pequeños) pero sería la elección equivocada para trabajo criptográfico o de simulación.
La tarjeta de la página de inicio
Tres de las plantillas comparten un lenguaje de layout — panel de mascota azafrán, título azul marino, footer de línea fina. La cuarta, home, es su propia cosa: gradiente radial morado profundo, tipografía cinética al estilo del hero en vivo (“SMALL / BRANDS / BIG APPETITES.”), y un disco azafrán que aloja la marca. La hicimos la única tarjeta que no encaja en una grilla de plantilla.
Para la variante en español, sacamos el copy directamente de los strings del hero de i18n/es.json — IDEAS / FRESCAS / UN GRAN APETITO. La misma forma, contenido correcto para el idioma.
Un pequeño detalle en esta tarjeta: las líneas de dos palabras (p. ej. “BIG APPETITES.”) desbordan la columna del título a 92px en español porque los caracteres en español ocupan más ancho. Agregamos una heurística — cualquier línea que contenga un espacio baja a 72px:
const size = line.includes(" ") ? 72 : 92;
Sin medición de fuente, solo una suposición que resulta ser correcta para el copy de la página de inicio en ambos idiomas. Ajustaremos la heurística cuando se sume un tercer idioma.
Consideraciones de despliegue en producción
Horneamos cada tarjeta en tiempo de build, así que el costo del sistema en runtime es cero. Es una decisión deliberada dado lo que hay aguas abajo. Cloudflare Pages y Workers tienen límites duros de los que queríamos mantenernos bien lejos: 128 MB de memoria por isolate, 10ms de CPU en el plan Free (hasta 5 minutos en el plan Paid), y un bundle de Worker comprimido de 10MB en el plan Paid. El binario WASM de @resvg/resvg-js es considerable, y renderizar en tiempo de request quemaría una porción significativa de ese presupuesto de CPU por tarjeta.
Existe un paquete aparte, workers-og, que existe específicamente porque la forma en que @vercel/og empaqueta el WASM no carga limpiamente bajo Cloudflare Workers; su README menciona el problema directamente. Evitamos todo ese problema pre-renderizando — pero si alguna vez necesitáramos tarjetas en runtime (enlaces de compartir con etiquetas UTM, variantes de A/B), workers-og es por donde empezaríamos.
Qué haríamos distinto
Algunas cosas que ya queremos revisar:
- Variable fonts. Enviamos JetBrains Mono y Titillium Web como TTFs de peso fijo porque el soporte de variable fonts de Satori no está documentado. La familia completa Urbanist vive en
public/fonts/y nos encantaría conectarla — necesita más pruebas en Satori. - Una ruta de renderizado con web-worker. Ahora mismo cada tarjeta OG vive en
getStaticPaths, horneada en tiempo de build. Si alguna vez necesitáramos tarjetas bajo demanda, moveríamos el renderizador a un Cloudflare Worker víaworkers-og. - Un fixture de auto-test. Renderizamos a mano cada plantilla una vez después de cada cambio. Un test de snapshot que compare los PNGs de salida contra fixtures versionados habría cazado el bug del CTA en segundos.
Todo el stack
Para cualquiera que quiera clonar el patrón, son tres paquetes:
satori— JSX → SVG@resvg/resvg-js— SVG → PNG- El framework que ya estés usando para la capa de rutas (nosotros usamos Astro)
Más los assets de marca: una mascota por mood, las fuentes de marca como buffers TTF crudos, y una paleta curada. El tamaño total del sistema — código + fuentes + SVGs — es menos de 400KB.
La fábrica lleva dos semanas funcionando. Hemos cambiado plantillas cuatro veces, intercambiado un pool de CTA una vez, y enviado un arreglo de bug. Las tarjetas siguen viéndose como una marca, no como un default.
— The Geek Cat

