·5 min de lectura·Mounaji Studio

Tarjeta de WhatsApp para una página estática, sin navegador en el servidor

Nuestras propuestas se publican como carpetas estáticas, y pegadas en WhatsApp salían como un link pelado. WhatsApp no muestra SVG, y no queríamos un navegador headless en el contenedor para dibujar una miniatura. Un rasterizador nativo con las fuentes adentro ahora escribe un PNG de 1200×630 en cada publicación. El circuito, los dos ajustes de Next.js que pide y qué pasa cuando falla.

engineeringwebimagesnextjs

También en: English

Las propuestas para clientes las escribimos en un editor y cada una se publica como una carpeta estática: un index.html, su logo y nada que necesite un servidor para mostrarse. El link casi siempre viaja por WhatsApp. Hasta el 2 de octubre de 2026, pegarlo ahí mostraba la URL pelada: sin imagen, sin un título que se pudiera leer. Lo primero que el cliente veía de la propuesta era una tira de caracteres.

La tarjeta que muestra WhatsApp sale del og:image de la página. Ya dibujábamos una tarjeta por propuesta, pero en SVG, y WhatsApp no muestra SVG. Necesita una imagen rasterizada (PNG o JPEG) detrás de una URL absoluta.

La respuesta habitual es sacar una captura con un navegador headless: Puppeteer o Playwright con Chromium. Para una imagen de 1200×630 hecha con un título y un logo, eso es un navegador entero instalado en un contenedor node:20-alpine, arrancando en cada publicación. Fuimos por el otro lado: dibujar la tarjeta en SVG y rasterizarla con resvg, un renderizador que viene como binario nativo precompilado para cada plataforma, linux-musl incluida.

El circuito

Publicar, con la tarjeta, en cuatro pasos: el servidor arma la tarjeta en SVG de 1200×630 con el logo como data URI; resvg la pasa a PNG usando sólo las fuentes Geist que trae; compartir.png queda al lado de index.html; el HTML anuncia og:image con URL absoluta, sólo si el archivo existe

  1. Al publicar, el servidor arma la tarjeta como un SVG de 1200×630: el título de la propuesta y el logo de la marca, con sus colores.
  2. resvg convierte ese SVG en PNG.
  3. El PNG queda como compartir.png al lado del index.html. La propuesta sigue siendo una carpeta estática.
  4. El HTML lleva sus etiquetas og: y twitter:, con la imagen como URL absoluta. WhatsApp no resuelve rutas relativas.

La tarjeta de una propuesta real pesó 33 KB.

Las fuentes viajan con el paquete

Ésta es la parte fácil de hacer mal. resvg dibuja el texto con las fuentes que le des. Un contenedor mínimo no tiene fuentes instaladas, y si lo dejás cargar las del sistema, el mismo SVG sale distinto en tu máquina y en el servidor. Por eso el rasterizador carga sólo las TTF de Geist que vienen dentro del paquete, y ninguna otra:

import { Resvg } from '@resvg/resvg-js';
import { fileURLToPath } from 'node:url';

const FUENTES = ['Geist-Regular.ttf', 'Geist-Medium.ttf', 'Geist-SemiBold.ttf', 'Geist-Bold.ttf']
  .map((f) => fileURLToPath(new URL(`../fuentes/${f}`, import.meta.url)));

export function svgAPng(svg) {
  const png = new Resvg(svg, {
    font: { fontFiles: FUENTES, loadSystemFonts: false, defaultFontFamily: 'Geist' },
  }).render().asPng();
  return new Uint8Array(png);
}

Dos detalles:

  • Sin fuentes, resvg no falla: deja el texto afuera. La tarjeta vuelve como un fondo con un logo y sin título, y nada tira error. Nuestro test para eso rasteriza la misma tarjeta con y sin título y exige que los dos PNG sean distintos, y que el que tiene texto pese claramente más. Un test que sólo mirara "salió un PNG" pasaría con el título faltando.
  • Las imágenes van como data URI. resvg no sale a la red ni lee el disco por su cuenta. Un <image href="logo.png"> sale como un hueco. El logo se lee del disco y se mete en base64 antes de rasterizar.

Los dos ajustes de Next.js

El endpoint que publica vive en una app de Next.js compilada con output: 'standalone'. Las dos cosas de las que depende el rasterizador se pierden en el bundle: resvg carga un binario nativo .node, y las fuentes se encuentran con import.meta.url, que dentro de un bundle apunta a cualquier lado. Lo arreglan dos ajustes:

// next.config.mjs
const nextConfig = {
  output: 'standalone',
  // No bundlear estos: uno carga un binario nativo, el otro busca sus
  // fuentes relativas a su propio archivo.
  serverExternalPackages: ['@resvg/resvg-js', '<tu-paquete-rasterizador>'],
  // El tracer sólo copia lo que ve importado. Las fuentes que se leen por una
  // ruta armada en runtime, y el binario de la plataforma, hay que listarlos.
  outputFileTracingIncludes: {
    '/api/**': [
      './node_modules/<tu-paquete-rasterizador>/fuentes/**',
      './node_modules/@resvg/resvg-js*/**',
    ],
  },
};
export default nextConfig;

Los listamos para todas las rutas de la API (/api/**) porque se publica desde más de una. Sin el segundo ajuste el contenedor arranca sano y falla la primera vez que alguien publica, que es el peor momento para enterarse. Lo comprobamos de la forma directa: un docker exec dentro del contenedor desplegado rasterizó un SVG con texto y devolvió un PNG.

Si falla, la página sale igual

La tarjeta suma. Una propuesta que no sale porque falló su miniatura es un problema de verdad. Por eso el paso que escribe la tarjeta atrapa su propio error, lo anota en el log y no devuelve nada; el HTML sólo anuncia og:image si el archivo existe:

const tarjeta = await escribirTarjeta(dir, contenido); // null si el motor falló
const html = generar(contenido, { url, ...(tarjeta ? { imagen: tarjeta } : {}) });

Un link sin tarjeta es donde estábamos el día anterior. Una propuesta que no sale es un problema nuevo, y la miniatura nunca vale eso.

Qué probamos

prueba qué demuestra
publicar escribe compartir.png firma de PNG real, 1200×630, og:image absoluta, summary_large_image
la misma tarjeta con y sin título el texto se dibuja de verdad
escribir en una carpeta que no existe publicar vuelve sin tarjeta en vez de fallar

Antes de copiarlo

  • Sirve para tarjetas que diseñás, no para capturas de la página. Si la vista previa tiene que verse idéntica a la página renderizada, con su CSS, ahí sí necesitás un navegador.
  • resvg lee TTF y OTF, no WOFF2. Las fuentes que servís en tu sitio probablemente no sirvan tal cual.
  • Mirá el contenedor desplegado, no sólo el build. Una fuente o un binario que falta aparece recién en runtime, así que la prueba que cuenta es rasterizar texto dentro del contenedor que está corriendo.

Cada link de propuesta que mandamos ahora llega con su tarjeta, y el servidor sigue sin un navegador adentro. Si mandás links a tus clientes y querés ver cómo se ven del otro lado, escribinos.

Comentarios y correcciones

Chat with us