Open Graph, Twitter Cards y og:image dinámica
Cómo se ve tu página cuando alguien la comparte: emitir las etiquetas Open Graph y Twitter Card desde el componente SEO, entender los requisitos de la imagen social, y generar una og:image única por página con un endpoint que renderiza PNG a partir del contenido usando satori y resvg, con sus diferencias entre estático y servidor.
La tarjeta social: Open Graph y Twitter
Open Graph es un protocolo de metadatos —nacido en Facebook, hoy universal— que describe tu página como un objeto compartible mediante etiquetas <meta property="og:...">. Las esenciales son og:title, og:description, og:url, og:type y, la que más pesa visualmente, og:image. X añade su propio dialecto con twitter:card, twitter:title y compañía, aunque en la práctica lee las etiquetas og: como respaldo, de modo que a menudo basta declarar twitter:card con el valor summary_large_image para que reutilice el resto.
La clave mental es que estas etiquetas son una API pública sobre cómo aparece tu contenido fuera de tu sitio. No las consume un humano leyendo el HTML, sino un rastreador ajeno que las lee una vez, cachea el resultado con avidez y dibuja la tarjeta. Por eso conviene derivarlas del mismo frontmatter que ya gobierna la página —una sola fuente de verdad— en lugar de mantener un segundo juego de textos que tarde o temprano se desincroniza del primero.
---// dentro de src/components/SEO.astroconst { title, description, type = 'website', canonical = new URL(Astro.url.pathname, Astro.site),} = Astro.props;const ogImage = new URL(`/og${Astro.url.pathname}.png`, Astro.site);---<meta property="og:title" content={title} /><meta property="og:description" content={description} /><meta property="og:type" content={type} /><meta property="og:url" content={canonical.toString()} /><meta property="og:image" content={ogImage.toString()} /><meta property="og:image:width" content="1200" /><meta property="og:image:height" content="630" /><meta name="twitter:card" content="summary_large_image" />Para contenido de tipo artículo, Open Graph define etiquetas propias —og:type con valor article, más article:published_time y article:author— y conviene declarar también og:site_name y og:locale para que la tarjeta muestre el nombre del sitio y su idioma. Todo sale del mismo frontmatter, sin una segunda fuente que mantener.
---const { type = 'website', publishedTime } = Astro.props;---<meta property="og:site_name" content="nvim-dios" /><meta property="og:locale" content="es_ES" />{type === 'article' && publishedTime && ( <meta property="article:published_time" content={publishedTime} />)}Los requisitos de la imagen social
La og:image tiene reglas que, incumplidas, arruinan la tarjeta en silencio. Debe ser una URL absoluta: los rastreadores no resuelven rutas relativas, así que resuélvela siempre contra Astro.site con new URL. La proporción de referencia es 1200 por 630 píxeles, un rectángulo ancho que ninguna red recorta; declarar og:image:width y og:image:height ayuda a las plataformas a reservar el hueco antes de descargarla. Y el peso importa: una imagen de varios megabytes puede tardar tanto que el rastreador se rinde y muestra la tarjeta sin foto.
El problema de fondo es que una imagen social buena es específica de la página —lleva su título, quizá su autor o su categoría— y nadie va a diseñar cientos a mano. Antes esto obligaba a elegir entre una única imagen genérica para todo el sitio, pobre pero barata, o un trabajo de diseño insostenible. La salida moderna es tratar la imagen como un dato derivado más: un artefacto que se computa a partir del contenido, igual que el sitemap o el feed.
Dos añadidos elevan la calidad de la tarjeta. La etiqueta og:image:alt describe la imagen para quien usa un lector de pantalla, un gesto de accesibilidad que las buenas plataformas respetan. Y en el dialecto de X conviene elegir con criterio entre summary, la tarjeta pequeña de miniatura cuadrada, y summary_large_image, la grande y apaisada: la primera encaja para notas breves, la segunda para artículos con una imagen que merece protagonismo.
Generar la og:image con un endpoint
La técnica consiste en exponer una ruta como src/pages/og/[...slug].png.ts que devuelve una imagen PNG por página. Dentro se usa satori, una librería que convierte un árbol de elementos tipo JSX —con un subconjunto de CSS flexbox— en un SVG, y luego resvg para rasterizar ese SVG a PNG. El endpoint recibe los datos de la página por props desde getStaticPaths, dibuja la plantilla con el título real y responde con los bytes de la imagen.
// src/pages/og/[...slug].png.tsimport type { APIRoute } from 'astro';import { getCollection } from 'astro:content';import satori from 'satori';import { Resvg } from '@resvg/resvg-js';export async function getStaticPaths() { const posts = await getCollection('blog'); return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));}export const GET: APIRoute = async ({ props }) => { const { post } = props; const svg = await satori( { type: 'div', props: { children: post.data.title, style: { width: '100%', height: '100%', display: 'flex', padding: 80, background: '#11111b', color: '#cdd6f4', fontSize: 64, alignItems: 'flex-end', }, }, }, { width: 1200, height: 630, fonts: [await cargarFuente()] }, ); const png = new Resvg(svg).render().asPng(); return new Response(png, { headers: { 'Content-Type': 'image/png' }, });};El getStaticPaths es el mismo mecanismo de las rutas dinámicas: enumera cada post y le asigna su URL de imagen, de modo que en el build se materializa un PNG por artículo. La plantilla no es más que una caja con estilos flexbox; ampliarla es cuestión de anidar más div con el autor, la fecha o un logotipo. Como todo nace del post.data, cambiar un título regenera su imagen en la siguiente compilación, sin intervención manual.
Un detalle sobre satori vale oro cuando lo usas por primera vez: no conoce las fuentes de tu sistema. Hay que entregarle los datos de la fuente —un .woff o .ttf cargado como buffer— porque satori es un renderizador minúsculo y autónomo, sin acceso al sistema operativo. Esa misma autonomía es su virtud: dibuja igual en tu portátil que en un servidor de compilación desnudo, sin depender de qué fuentes haya instaladas. Verlo por lo que es ayuda: un motor que toma una descripción declarativa —un árbol de cajas con estilos— y produce un vector, igual que un navegador toma HTML y CSS y produce píxeles, pero recortado al mínimo para una imagen estática. Piensas la tarjeta con las mismas herramientas mentales que una página.
Si generas la imagen bajo demanda en SSR, añade cabeceras de caché para no rasterizarla en cada petición:
return new Response(png, { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=31536000, immutable', },});Estático frente a servidor
Dónde se genera la imagen depende del modo de renderizado. En un sitio estático, getStaticPaths enumera todas las páginas en el build y los PNG quedan escritos como ficheros: el coste se paga una vez al compilar y servir la imagen es tan barato como servir cualquier archivo. En SSR, puedes renderizar la imagen bajo demanda en la primera petición y cachearla en el borde, útil cuando el contenido cambia sin recompilar —un perfil de usuario, un producto con precio vivo—. El compromiso es el de siempre: el estático regala velocidad a cambio de rigidez; el servidor regala frescura a cambio de cómputo por petición.
Un aviso de escala para el modo estático: si tu sitio tiene decenas de miles de páginas, rasterizar una imagen para cada una alarga el build de forma notable, porque cada PNG cuesta su tiempo de satori y de resvg. Ahí el modo bajo demanda con caché al borde puede salir más barato en conjunto, porque pagas el dibujo solo de las imágenes que alguien llega a compartir, no de todas por adelantado. Es la misma tensión entre precomputar y computar al vuelo que atraviesa toda decisión de renderizado en Astro.