En las aplicaciones web modernas, las redes sociales, los motores de búsqueda y las aplicaciones de mensajería (Telegram, Slack, Twitter/X, Discord, LinkedIn, iMessage) leen los metadatos OpenGraph (og:image, og:title) y Twitter Cards (twitter:image) para construir tarjetas de vista previa enriquecidas (Rich Social Snippets). Una tarjeta visual atractiva y contextual eleva directamente la tasa de interacción y clics (CTR).
Históricamente, existían dos enfoques antagónicos para crear estas imágenes dinámicas:
Next.js 15 resuelve esta problemática mediante su subsistema nativo de generación de imágenes ImageResponse (paquete @vercel/og), impulsado por el motor Satori. Satori convierte JSX con un subconjunto estricto de CSS Flexbox directamente en vectores SVG mediante el motor de cálculo geométrico Yoga Layout Engine. El SVG resultante se rasteriza inmediatamente a PNG optimizado mediante el módulo WebAssembly de alto rendimiento Resvg. Todo el ciclo se completa en menos de 20–50 milisegundos consumiendo apenas unos megabytes de memoria, lo que permite generar gráficos personalizados directamente en nodos Edge CDN.
Next.js 15 implementa el paradigma de File-based Metadata — una convención de sistema de archivos donde archivos con nombres específicos en la jerarquía de rutas se compilan de manera automática en etiquetas HTML de la sección <head>.
Los metadatos basados en archivos tienen prioridad sobre la configuración estática de metadata.openGraph.images y se vinculan automáticamente al segmento de ruta correspondiente.
+-------------------------------------------------------------------------+
| app/blog/[slug]/opengraph-image.tsx |
| Plantilla JSX: contenedor Flexbox, título, avatar y logotipo |
+-------------------------------------------------------------------------+
│
▼ (1. Análisis de marcado y estilos)
+-------------------------------------------------------------------------+
| Satori Engine (Yoga) |
| - Cálculo geométrico del modelo Flexbox |
| - Inserción de glifos vectoriales de fuentes (TTF / WOFF) |
| - Generación del árbol DOM SVG en memoria |
+-------------------------------------------------------------------------+
│
▼ (2. Flujo vectorial SVG)
+-------------------------------------------------------------------------+
| Resvg WASM Core |
| - Rasterización de alta velocidad de curvas vectoriales a píxeles |
| - Compresión en formato de imagen PNG (1200 x 630 px) |
+-------------------------------------------------------------------------+
│
▼ (3. HTTP ImageResponse)
+-------------------------------------------------------------------------+
| HTTP/2 200 OK (Content-Type: image/png) |
| Cache-Control: public, immutable, max-age=31536000 |
+-------------------------------------------------------------------------+icon.tsx y apple-icon.tsx ajustando temas de color o indicadores de notificación según el entorno.| Archivo | Propósito | Etiqueta HTML Generada |
| :--- | :--- | :--- |
| favicon.ico | Icono estándar de la pestaña del navegador | <link rel="icon" href="/favicon.ico" sizes="any" /> |
| icon.(ico\|jpg\|png\|svg\|tsx) | Favicon programático o personalizable | <link rel="icon" href="/icon?<hash>" type="image/..." sizes="..." /> |
| apple-icon.(png\|jpg\|tsx) | Icono táctil para la pantalla de inicio de Apple iOS | <link rel="apple-touch-icon" href="/apple-icon?<hash>" sizes="..." /> |
| opengraph-image.(png\|jpg\|tsx) | Tarjeta para redes compatibles con OpenGraph | <meta property="og:image" content="https://.../opengraph-image?<hash>" /> |
| twitter-image.(png\|jpg\|tsx) | Tarjeta dedicada de formato grande para Twitter / X | <meta name="twitter:image" content="https://.../twitter-image?<hash>" /> |
| manifest.(json\|webmanifest) | Manifiesto de Progressive Web App (PWA) | <link rel="manifest" href="/manifest.webmanifest" /> |
app/blog/[slug]/opengraph-image.tsx)#En opengraph-image.tsx, exportamos constantes de configuración (size, contentType, alt, runtime) junto con una función por defecto que devuelve una instancia de ImageResponse.
import { ImageResponse } from 'next/og';
import { getPostBySlug } from '@/lib/data/posts';
// 1. Configuración de runtime y dimensiones de la imagen
export const runtime = 'edge'; // Ejecución en nodos Edge globales de Vercel/Cloudflare
export const alt = 'Vista previa de publicación en Fullstack Agentic Lab';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
interface Props {
params: Promise<{
slug: string;
}>;
}
// 2. Función principal de generación de imagen
export default async function Image({ params }: Props) {
const { slug } = await params;
const post = await getPostBySlug(slug);
const title = post?.title || 'Ingeniería Web y Plataformas';
const category = post?.category || 'Next.js 15';
const authorName = post?.author?.name || 'Fullstack Agentic Lab Team';
// 3. Carga del archivo de fuente binaria en un ArrayBuffer
// Satori no dispone de fuentes del sistema operativo en el Edge Runtime
const fontData = await fetch(
new URL('@/assets/fonts/Inter-Bold.ttf', import.meta.url)
).then((res) => {
if (!res.ok) throw new Error('Error al cargar la fuente');
return res.arrayBuffer();
});
return new ImageResponse(
(
// Contenedor raíz Flexbox de Satori (JSX con estilos en línea)
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
backgroundColor: '#09090b',
backgroundImage: 'radial-gradient(circle at 25px 25px, #27272a 2%, transparent 0%)',
backgroundSize: '40px 40px',
padding: '80px',
fontFamily: 'Inter',
}}
>
{/* Barra superior: logotipo y badge de categoría */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '8px',
backgroundColor: '#06b6d4',
}}
/>
<span
style={{
fontSize: '28px',
fontWeight: 800,
color: '#ffffff',
letterSpacing: '-0.03em',
}}
>
Fullstack Agentic Lab
</span>
</div>
<div
style={{
display: 'flex',
padding: '8px 20px',
borderRadius: '9999px',
backgroundColor: 'rgba(6, 182, 212, 0.1)',
border: '1px solid rgba(6, 182, 212, 0.3)',
color: '#06b6d4',
fontSize: '18px',
fontWeight: 700,
textTransform: 'uppercase',
}}
>
{category}
</div>
</div>
{/* Bloque central: titular de la publicación */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
maxWidth: '1000px',
}}
>
<h1
style={{
fontSize: '64px',
fontWeight: 800,
color: '#ffffff',
lineHeight: 1.15,
letterSpacing: '-0.04em',
}}
>
{title}
</h1>
</div>
{/* Barra inferior: autor y dominio */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
borderTop: '1px solid #27272a',
paddingTop: '32px',
}}
>
<span
style={{
fontSize: '22px',
color: '#a1a1aa',
fontWeight: 600,
}}
>
Autor: {authorName}
</span>
<span
style={{
fontSize: '22px',
color: '#71717a',
}}
>
agenticlab.dev
</span>
</div>
</div>
),
{
...size,
fonts: [
{
name: 'Inter',
data: fontData,
style: 'normal',
weight: 700,
},
],
// Cabeceras de control de caché en la CDN
headers: {
'Cache-Control': 'public, immutable, no-transform, max-age=31536000',
},
}
);
}app/icon.tsx)#Next.js también admite la generación dinámica del favicon del sitio web:
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const size = { width: 32, height: 32 };
export const contentType = 'image/png';
export default function Icon() {
return new ImageResponse(
(
<div
style={{
fontSize: 20,
background: '#09090b',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#06b6d4',
borderRadius: '6px',
border: '1px solid #27272a',
fontWeight: 900,
}}
>
W
</div>
),
{
...size,
}
);
}Satori no es un motor de navegador completo. Solo implementa un subconjunto restringido de CSS soportado por Yoga Flexbox. Intentar utilizar display: grid, float, pseudoelementos ::before o ::after, selectores CSS avanzados, animaciones (@keyframes) o filtros gráficos (backdrop-filter) generará errores de compilación o será ignorado silenciosamente. Los diseños para Satori deben estructurarse estrictamente sobre display: 'flex' con flexDirection, alignItems, justifyContent y estilos en línea directos.
En Vercel Edge Runtime y Cloudflare Workers no existen fuentes preinstaladas del sistema operativo (como Arial o Helvetica). Si en ImageResponse no se especifica el arreglo fonts con un búfer binario válido (ArrayBuffer), Satori intentará descargar la fuente Noto Sans a través de una llamada de red externa. Esto ralentizará drásticamente la respuesta o provocará un error fatal Font loading failed ante fallos de conexión. Suministre siempre archivos locales de fuente TTF o WOFF.
El límite de tamaño para funciones en Edge Runtime oscila habitualmente entre 1 MB y 4 MB. Incluir una fuente TTF completa con todos los caracteres asiáticos y cirílicos puede inflar el bundle en más de 10–20 MB, provocando fallos en el despliegue. Utilice Subsetting de fuentes (manteniendo únicamente el conjunto de caracteres necesario) o emplee archivos WOFF optimizados menores a 50–100 KB.
Si no se define la cabecera Cache-Control, cada petición de un crawler o bot de red social forzará la reejecución de la función ImageResponse, consumiendo cuotas de CPU innecesariamente. Las imágenes OpenGraph de artículos inmutables deben declarar una directiva estricta de caché: 'Cache-Control': 'public, immutable, no-transform, max-age=31536000'.
Un crawler de Facebook o Telegram solicita /blog/react-19/opengraph-image.
El worker de Edge recupera en paralelo los metadatos del artículo y los búferes TTF/WOFF.
El motor Yoga calcula las coordenadas Flexbox y genera el árbol vectorial SVG en memoria.
El módulo WebAssembly compila inmediatamente los vectores y glifos en píxeles PNG.
El flujo binario PNG se responde con cabeceras immutable y se almacena en caché en el Edge.