Neste post, quero compartilhar uma experiência rápida que tive usando Remix e te mostrar como gerar imagens sociais automáticas com Open Graph de um jeito bem prático.

O que é Open Graph (OG)?

Todos nós já copiamos um link interessante e colamos numa conversa com um amigo no WhatsApp, Facebook, X (antigo Twitter), LinkedIn ou outra rede social. E antes mesmo de enviar, o app já carrega uma prévia com imagem, título e descrição do conteúdo, certo? Isso é possível graças ao protocolo Open Graph, que usa metatags HTML para fornecer essas informações sobre a página.

Exemplo da OG gerada nesse projeto

Exemplo da OG gerada nesse projeto

O que a IA diz sobre OG?
”Open Graph é um protocolo que permite a personalização do conteúdo exibido quando uma página web é compartilhada em redes sociais, como Facebook e Twitter. Ele utiliza tags HTML (metatags) para fornecer informações específicas sobre a página, como título, descrição e imagem, controlando assim a apresentação do link compartilhado.”

Para se aprofundar mais, recomendo o site oficial: https://ogp.me/

Minha experiência implementando OG no Remix

No início deste ano, 2025, precisei criar um OG em um projeto onde eu estava trabalhando como freelancer, o projeto estava utilizando Remix, por isso a implantação da feature foi divertida e surpreendentemente simples.

Até então, nunca havia feito algo do tipo. Comecei pesquisando e logo descobri o site do ogp. Como as informações ficam nas metatags, fiz um teste hardcoded só para ver se funcionava e... funcionou! Simples assim.

Mas como a vida de dev nem sempre é só hardcode (às vezes até usamos, mas convenhamos: o desafio é o que deixa tudo mais interessante), decidi partir para a parte divertida...

Mãos na massa: Criando OG Images Dinâmicas com React Router V7

Tentei deixar o mais simples esse projeto, fique à vontade para expandir com sua criatividade, no final desse artigo deixarei o link do projeto completo.

Por que React Router v7?

Uma das vantagens de usar o React Router v7 (ou o Remix, que já vem com ele por baixo dos panos) é a flexibilidade que ele dá pra organizar suas rotas de forma declarativa e lógica. Além disso, com o React Router v7 fica fácil configurar loaders, lazy loading e até evitar que certas rotas sejam renderizadas no cliente, perfeito pra esse tipo de rota que só serve pra gerar uma imagem no backend.

Configurando o projeto

Primeiro, vamos iniciar nosso ambiente

npx create-react-router@latest og-image-generation

Em seguida instalei algumas dependências

  • satori - converte HTML e CSS para SVG (Tailwind está em uma versão experimental, quem sabe um próximo post!?!?)
  • @resvg/resvg-js - converte SVG para PNG
  • fast-average-color-node - extrai informações de imagens (Coloquei essa dependência para fazer um degrade no background do card conforme a cor predominante da imagem)
npm install satori @resvg/resvg-js fast-average-color-node

Depois disso, utilizei o fast-average-color-node para pegar informações da imagem.

import { getAverageColor } from "fast-average-color-node";

const backgroundColor = await getAverageColor(image);

// backgroundColor {
//   value: [ 108, 98, 72, 255 ],
//   rgb: 'rgb(108,98,72)',
//   rgba: 'rgba(108,98,72,1)',
//   hex: '#6c6248',
//   hexa: '#6c6248ff',
//   isDark: true,
//   isLight: false,
//   error: undefined
// }

Com isso percebi que podemos criar um efeito bem legal com gradiente na cor de fundo do nosso card, note que o hex, onde possui a cor predominante, pode ser concatenado com E6 e 1A para mexermos na opacidade da cor.

Porcentagem Hex
100% FF
90% E6
80% CC
70% B3
60% 99
50% 80
40% 66
30% 4D
20% 33
10% 1A
0% 00
`linear-gradient(
  to top right,
  ${backgroundColor.hex}E6,
  ${backgroundColor.hex}1A
)`

Existem várias formas de aplicar opacidade em um gradiente, mas optei por esse caminho porque queria explorar melhor como funciona a opacidade direto no valor hexadecimal. Mas, pra quem curte experimentar, vou deixar aqui mais duas formas de fazer isso.

const backgroundColor = {
  value: [108, 98, 72, 255],
  rgba: 'rgba(108,98,72,1)',
};

const { value, rgba } = backgroundColor

// Exemplo 01
const [r, g, b] = value
console.log(`rgba(${r}, ${g}, ${b}, 0.8)`)

// Exemplo 02
console.log(rgba.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)/, `rgba($1, $2, $3, 0.8)`))

Utilizei o satori para montar o SVG com HTML e CSS (para quem curte Tailwind, como eu 😬, existe uma feature experimental)

const svg = await satori(
    <div
      style={{
        ..., // código omitido
        background: `linear-gradient(to top right, ${backgroundColor.hex}E6, ${backgroundColor.hex}1A)`,
        color: backgroundColor.isDark ? "white" : "black"
      }}
    >
      <div>
        <div
          style={{
            ..., // código omitido
            color: backgroundColor.isDark ? "white" : "black",
          }}
        >
          <img
            src="http://localhost:5173/my-logo.svg"
            alt="My Logo"
            style={{
              ..., // código omitido
              filter: backgroundColor.isDark ? "invert(0.8)" : "invert(0.4)",
            }}
          />
          <h1
            style={{
              ..., // código omitido
              color: backgroundColor.isDark ? "white" : "black",
            }}
          >
            {title}
          h1>
          <p
            style={{
              ..., // código omitido
              color: backgroundColor.isDark ? "#dddddd" : "#636363",
            }}
          >
            {shortDescription}
          p>
        div>
        <img
          src={image}
          alt="OG Image"
        />
      div>
    div>,
    {
      width: 800,
      height: 420,
      fonts: [ // código omitido ],
    }
  );

Também utilizei o backgroundColor.isDark para inverter cores como textos e a logo

No final precisava converter o SVG para PNG então utilizei o Resvg para isso

Pelas minhas pesquisas o protocolo og trabalha melhor com

  • PNG
  • JPEG
  • GIF (geralmente apenas o primeiro quadro é renderizado)
const pngData = new Resvg(svg).render().asPng();
return pngData;

Segue abaixo todo o código da função para gerar a imagem

import satori from "satori";
import { Resvg } from "@resvg/resvg-js";
import { getAverageColor } from "fast-average-color-node";

async function generateOgImage({
  title,
  shortDescription,
  image,
}: {
  title: string;
  shortDescription: string;
  image: string;
}) {
  const backgroundColor = await getAverageColor(image);
  const svg = await satori(
    <div
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        width: 800,
        height: 420,
        background: `linear-gradient(to top right, ${backgroundColor.hex}E6, ${backgroundColor.hex}1A)`,
        color: backgroundColor.isDark ? "white" : "black",
        fontFamily: "Inter, sans-serif",
      }}
    >
      <div
        style={{ display: "flex", width: "90%", maxWidth: 800, paddingLeft: 6 }}
      >
        <div
          style={{
            flex: 1,
            display: "flex",
            flexDirection: "column",
            justifyContent: "space-between",
            alignItems: "flex-start",
            paddingTop: 24,
            paddingRight: 40,
            paddingBottom: 24,
            paddingLeft: 0,
            color: backgroundColor.isDark ? "white" : "black",
          }}
        >
          <img
            src="http://localhost:5173/my-logo.svg"
            alt="My Logo"
            style={{
              alignSelf: "flex-start",
              height: 24,
              filter: backgroundColor.isDark ? "invert(0.8)" : "invert(0.4)",
            }}
          />
          <h1
            style={{
              display: "flex",
              alignItems: "center",
              justifyContent: "flex-start",
              flexGrow: 1,
              fontSize: 32,
              fontWeight: "600",
              marginTop: 20,
              lineHeight: "2.25rem",
              color: backgroundColor.isDark ? "white" : "black",
            }}
          >
            {title}
          h1>
          <p
            style={{
              alignSelf: "flex-start",
              fontSize: 16,
              fontWeight: "400",
              color: backgroundColor.isDark ? "#dddddd" : "#636363",
            }}
          >
            {shortDescription}
          p>
        div>
        <img
          src={image}
          alt="OG Image"
          style={{ width: 350, height: 350, borderRadius: "1rem" }}
        />
      div>
    div>,
    {
      width: 800,
      height: 420,
      fonts: [
        {
          name: "Inter",
          data: await fetch(
            "https://cdn.jsdelivr.net/npm/@fontsource/inter/files/inter-latin-400-normal.woff"
          ).then((res) => res.arrayBuffer()),
          weight: 400,
          style: "normal",
        },
        {
          name: "Inter",
          data: await fetch(
            "https://cdn.jsdelivr.net/npm/@fontsource/inter/files/inter-latin-600-normal.woff"
          ).then((res) => res.arrayBuffer()),
          weight: 600,
          style: "normal",
        },
      ],
    }
  );

  const pngData = new Resvg(svg).render().asPng();
  return pngData;
}

export { generateOgImage };

Agora, precisamos de uma rota que chame a função generateOgImage e devolva a imagem gerada como resposta. Pra isso, vamos usar o loader do Remix

❤️ Remix por deixar esse tipo de coisa tão direto!

Deixei esse exemplo bem simples, somente um array com URLs de imagens, escolherei uma imagem aleatoriamente usando Math.random(), mas em um cenário real, você poderia pegar os parâmetros da rota para buscar dados do seu DB e gerar a imagem com base em informações do post, produto ou até perfil do usuário. Dá para viajar bastante aqui, use a criatividade!

// app/routes/og.ts

import type { Route } from "./+types/og";
import { generateOgImage } from "~/components/generate-og-image";

const images = [
  "https://fastly.picsum.photos/id/857/800/800.jpg?hmac=BUGUS_K7Wesbr9xUV5ya8TfYHI04KBg_kWauQkuIgS0",
  "https://fastly.picsum.photos/id/835/800/800.jpg?hmac=PJZoPbB8PjU_6jPaR4U6KX7Mesx3F2_l-2tSCVeF2Cg",
  "https://fastly.picsum.photos/id/839/800/800.jpg?hmac=Rvd_0eo62Cj10Rsw4bxKOUjvU1qTc5fA6DfWEQ3cVSE",
  "https://fastly.picsum.photos/id/981/800/800.jpg?hmac=dL5YCGb-HqSsuYOPiCgADn_NjvaUsl6PJDR-FdSBvcU",
  "https://fastly.picsum.photos/id/34/3872/2592.jpg?hmac=4o5QGDd7eVRX8_ISsc5ZzGrHsFYDoanmcsz7kyu8A9A",
  "https://fastly.picsum.photos/id/102/4320/3240.jpg?hmac=ico2KysoswVG8E8r550V_afIWN963F6ygTVrqHeHeRc",
];
export async function loader({}: Route.LoaderArgs) {
  const pngData = await generateOgImage({
    title: "Find out how to generate an image for Open Graph.",
    shortDescription:
      "The Open Graph protocol enables any web page to become a rich object in a social graph.",
    image: images[Math.floor(Math.random() * images.length)],
  });

  return new Response(Buffer.from(pngData), {
    headers: {
      "Content-Type": "image/png",
    },
  });
}

Agora tudo que temos que fazer é preencher as tags com os valores necessários, o básico é:

  • og:title
  • og:type
  • og:image
  • og:url

Lembrando que cada rede social pode possuir sua tag para melhor apresentar os dados

export function meta({}: Route.MetaArgs) {
  return [
    { title: "Open Graph Image Generation App" },
    { name: "description", content: "Welcome to Open Graph Image Generation!" },
    { name: "og:title", content: "Open Graph Image Generation App" },
    {
      name: "og:description",
      content: "Welcome to Open Graph Image Generation!",
    },
    { name: "og:image", content: "http://localhost:5173/og/" },
    { name: "og:url", content: "http://localhost:5173/" },
    { name: "og:type", content: "website" },
    { name: "og:site_name", content: "Open Graph Image Generation" },
    { name: "og:locale", content: "en_US" },
  ];
}

Caso queira ver o projeto completo faça o clone do repositório, instale as dependências e execute-o.

git clone https://github.com/douglasheldpacito/og-image-generation.git

// dentro da pasta og-image-generation

npm install

npm run dev

Pra deixar tudo mais visual, criei uma página de exemplo que mostra como ficaria a imagem gerada com elas. É uma forma simples de testar e entender como o layout final vai se comportar com diferentes imagens.

A cada vez que você recarrega, uma imagem diferente é usada, ótimo pra testar o efeito do gradiente, das cores e da composição geral.

Curtiu a ideia? Se você já usa o Open Graph no seu site ou blog, me conta nos comentários! E se tiver dúvidas, manda ver também.

Abraços!