5 de julio de 2026
OG cards dinámicas en Astro: una por post, generadas en build
#astro #satori #cloudflare #seo
Comparte un enlace de casi cualquier web personal y la preview que aparece en LinkedIn o WhatsApp está en blanco o muestra un favicon genérico. Es una cosa pequeña, pero es la primera impresión del enlace — y en un sitio que acababa de rehacer, el valor por defecto se sentía como dejar el último botón sin abrochar.
Lo que quería: una card de marca por post, mostrando el título de ese post, generada automáticamente. Lo que no quería: un servicio de imágenes aparte, un navegador headless en el build, ni nada que necesitara un servidor en el momento de la petición. El sitio es estático y está en Cloudflare Pages — las cards deberían ser ficheros planos, generados una sola vez en el build.
flowchart LR
BUILD[astro build] --> HOOK["astro:build:done"]
HOOK --> TREE[árbol de la card]
TREE --> SATORI["satori → SVG"]
SATORI --> RESVG["resvg → PNG"]
RESVG --> OUT[("dist/og/*.png")]
Por qué en build, no en runtime
Lo tentador es generar la imagen al vuelo con una Cloudflare Function. Lo
descarté por dos motivos. Primero, las cards no cambian entre despliegues —
renderizarlas por petición es trabajo desperdiciado y un impuesto de cold-start
sobre algo que un scraper social toca una vez. Segundo, las librerías obvias de
imágenes dependen de fs/path de Node, que el prerender de workerd de
Cloudflare no provee del todo — acabas peleándote con el runtime en vez de
usarlo.
El dúo que esquiva todo eso: satori convierte un árbol tipo elemento-React en SVG, y resvg rasteriza ese SVG a PNG. Ambos son pure-Node, así que corren sin problema dentro del hook de build de Astro, sin navegador y sin dependencia de runtime.
El hook
Astro expone un hook astro:build:done que se dispara cuando la salida estática
ya está escrita, con el dir de salida en la mano. Ese es el momento de emitir
las imágenes. satori toma un árbol de objetos planos —sin JSX— así que la card
es solo una función pequeña que describe el layout, con el tema del sitio:
const el = (type, style, children) => ({ type, props: { style, children } })
const cardTree = (title, description) =>
el("div", {
width: 1200, height: 630, display: "flex", flexDirection: "column",
justifyContent: "space-between", backgroundColor: "#0a0e16", color: "#f4f8fd",
padding: "70px", borderLeft: "16px solid #38bdf8", fontFamily: "Onest",
}, [
el("div", { fontSize: 30, color: "#7d8ba1" }, "Daniel Márquez"),
el("div", { display: "flex", flexDirection: "column" }, [
el("div", { fontSize: 64, fontWeight: 700, lineHeight: 1.2 }, title),
el("div", { fontSize: 30, color: "#aab6c7", marginTop: 24, lineHeight: 1.4 }, description),
]),
el("div", { fontSize: 26, color: "#38bdf8" }, "danimarqz.dev"),
])
1200×630 es el tamaño que LinkedIn, X y el resto esperan para una summary card grande. El borde izquierdo y el color de acento son toda la “marca” que la card necesita — el título hace el resto.
Emitir una por post
El paso de render es pequeño: árbol → SVG → PNG → escribir en dist/og/.
const ogCards = {
name: "og-cards",
hooks: {
"astro:build:done": async ({ dir }) => {
const { default: satori } = await import("satori")
const { Resvg } = await import("@resvg/resvg-js")
const fonts = await loadFonts() // ver "trampas" más abajo
const outRoot = fileURLToPath(dir)
const emit = async (route, title, description) => {
const svg = await satori(cardTree(title, description), { width: 1200, height: 630, fonts })
const png = new Resvg(svg).render().asPng()
const out = join(outRoot, "og", `${route}.png`)
mkdirSync(dirname(out), { recursive: true })
writeFileSync(out, png)
}
await emit("site", "Daniel Márquez", "Backend & Cloud Engineer · Go · AWS · IA")
for (const rel of readdirSync(BLOG_DIR, { recursive: true })) {
if (!/\.mdx?$/.test(rel)) continue
const { data } = matter(readFileSync(join(BLOG_DIR, rel), "utf8"))
await emit(rel.replace(/\.mdx?$/, ""), data.title, data.description)
}
},
},
}
Una nota sobre leer el frontmatter: es tentador sacar el título con un regex
rápido, y para contenido totalmente controlado funciona — hasta que un título
lleva dos puntos (este blog tiene varios) o un valor entre comillas, y la card
renderiza medio título en silencio, sin error de build. Usar un parser de
verdad como gray-matter —que ya está en el árbol de dependencias de Astro— no
cuesta nada y elimina toda una clase de bugs del tipo “por qué sale la card
cortada”.
Trampas que conviene conocer
Algunas cosas que muerden en silencio:
- Fuentes. satori necesita los bytes de la fuente. Hacer fetch a un CDN en
el build funciona, pero ata el éxito de tu build a que ese CDN esté arriba.
Meter los
.ttfen el repo hace el build hermético — merece la pena para algo que corre en cada despliegue. - Casar el slug. Una lectura recursiva de directorio devuelve rutas anidadas
(
mi-post/es), así que el PNG emitido vive enog/mi-post/es.png. El meta tagog:imagede esa página tiene que apuntar exactamente a esa ruta — es el sitio más común donde todo se rompe en silencio. - URLs absolutas.
og:imagedebe ser una URLhttps://absoluta. Algunos scrapers no resuelven una relativa. - Cachés. LinkedIn, WhatsApp y compañía cachean las previews de forma agresiva. Si compartiste un enlace antes de que la card existiera, la preview vieja (o en blanco) se queda pegada — usa el inspector de la plataforma para forzar el refresco.
La lección
El reflejo ante “generar una imagen por página” es tirar de un runtime: una function, un servicio, un navegador. Pero cuando la salida está fijada en el build, el build es el runtime — y un hook más dos librerías pure-Node le ganan a cualquier cosa que tenga que despertarse en cada petición. Empuja el trabajo al momento más temprano en que puede ocurrir, y el resto del stack se vuelve más simple.