wandres.dev
SEO · metadatos, OG, JSON-LD

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.

⏱ 18 min

El título y la descripción gobiernan cómo se ve tu página en un buscador; las etiquetas Open Graph gobiernan cómo se ve en todo lo demás —un mensaje de WhatsApp, un post en LinkedIn, una tarjeta en Slack, un tuit—. Ahí un enlace desnudo no convence a nadie: lo que engancha es la tarjeta con imagen, título y descripción que el rastreador de cada plataforma compone leyendo tu <head>. En esta lección extendemos el componente <SEO /> con esos metadatos y damos el salto que separa un sitio corriente de uno pulido: generar una og:image distinta para cada página, dibujada a partir de su propio contenido, sin abrir un editor de imágenes ni una sola vez.

🎯 Al terminar esta lección sabrás
  • Emitir las etiquetas Open Graph y Twitter Card desde el componente <SEO />.
  • Conocer los requisitos de la imagen social: tamaño, proporción y URL absoluta.
  • Generar una og:image por página con un endpoint que renderiza PNG con satori y resvg.
  • Distinguir la generación en estático de la generación bajo demanda en SSR.

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.astro
const {
  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.

⚠️
Los rastreadores sociales cachean con avidez

Cuando una plataforma rastrea tu enlace por primera vez, guarda la tarjeta y no vuelve a mirar en mucho tiempo. Si publicas con una og:image rota y la arreglas después, la tarjeta vieja puede persistir durante días. Por eso cada red ofrece un depurador —el Sharing Debugger de Facebook, el Post Inspector de LinkedIn— que fuerza un nuevo rastreo. Valida la tarjeta antes de difundir el enlace, no después de que medio mundo ya la haya cacheado mal.

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.ts
import 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',
  },
});
💡
Si satori te parece mucho aparato, hay atajos

La integración astro-og-canvas envuelve esta misma idea en una API declarativa: le pasas título y descripción y ella devuelve el PNG, ocultando satori y resvg. Es el camino rápido cuando quieres imágenes sociales decentes sin plantillar a mano. Reserva el endpoint artesanal para cuando necesites control fino del diseño —tipografías propias, fondos por categoría, ilustraciones— que una API cerrada no te da.

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.

flowchart LR
FM[frontmatter del post] --> EP[endpoint og slug png]
EP --> SAT[satori arbol a svg]
SAT --> RES[resvg svg a png]
RES --> IMG[og image 1200 x 630]
FM --> SEO[componente SEO]
SEO --> META[meta og image url absoluta]
META --> BOT[rastreador social]
IMG --> BOT
BOT --> CARD[tarjeta con imagen]
style EP fill:#89b4fa,color:#11111b
style CARD fill:#a6e3a1,color:#11111b
style FM fill:#f9e2af,color:#11111b
Automatizar la imagen es automatizar una decisión de diseño

Generar la og:image con código cruza una frontera que solemos creer intransitable: la que separa lo que se programa de lo que se diseña. Una imagen parece pertenecer al reino de lo visual, de lo hecho a mano, de lo irreductiblemente humano; y sin embargo, cuando la miras de cerca, una tarjeta social es tan derivable de tu contenido como lo eran el sitemap o el feed. Tiene una plantilla fija —un fondo, una tipografía, una disposición— y un puñado de datos variables —el título, el autor, la fecha— que ya viven tipados en tu frontmatter. Reducir un artefacto a plantilla más datos es el acto fundacional de toda automatización, y una vez que lo ves posible en algo tan aparentemente visual como una imagen, empiezas a verlo en todas partes. Lo que has hecho no es solo ahorrarte diseñar cientos de imágenes: has convertido la identidad visual de tus enlaces compartidos en una propiedad del sistema, garantizada por construcción, imposible de olvidar, coherente en cada página porque todas salen del mismo molde alimentado por la misma fuente. El día que rediseñes la plantilla, las cientos de imágenes se rehacen solas en el siguiente build, sin que nadie repita el trabajo. Esa es la marca de un buen artefacto derivado: no envejece por partes, porque no existe por partes; existe como una función que se vuelve a evaluar entera cada vez. La lección que trasciende Astro es que la frontera entre lo diseñado y lo programado es más porosa de lo que parece, y que buena parte del trabajo repetitivo que creemos manual es, en realidad, una plantilla esperando a que alguien le pase los datos.

⚔️ Dibuja una imagen social por página
  1. Extiende <SEO /> para emitir og:title, og:description, og:url, og:image y twitter:card, resolviendo la imagen a una URL absoluta contra Astro.site.
  2. Crea un endpoint src/pages/og/[...slug].png.ts que use getStaticPaths para enumerar tus posts y satori más resvg para dibujar el título en una tarjeta de 1200 por 630.
  3. Compila, abre una de las imágenes generadas y valida un enlace real con el depurador de una red social hasta que la tarjeta salga con foto.
  4. Razona qué cambiaría si pasaras a SSR y decidieras generar la imagen bajo demanda: qué ganas en frescura y qué pagas en cómputo por petición.