Utilizando OG Image Generation do Next.js para criar imagens dinâmicas

26 minutes of reading

Recentemente apliquei um workshop sobre Desenvolvimento Front-end na Fitcard, e como forma de recompensa, decidi criar um certificado customizado para cada participante.

A ideia seria criar uma landing page na qual cada participante pudesse inserir seu nome, a fim de gerar um certificado personalizado com um elemento dinâmico e exclusivo, proporcionando uma sensação de singularidade.

Depois de várias ideias discutidas com meu amigo Renan Bonin, e tendo em vista que o workshop trazia alguns conceitos de grids do Bootstrap (bastante presente no contexto da empresa), chegamos a uma solução: gerar um certificado que calcularia o número necessário de colunas para exibir o nome do participante:

Modelo de certificado

Como vocês podem ver na imagem, temos doze colunas no fundo para remeter ao grid do Bootstrap, e uma linha (row), sendo dividida entre duas colunas: a primeira, com o nome (ocupando oito colunas neste caso), e a segunda, sendo representada por emojis aleatórios (sim, eu amo emojis). Tudo isso calculado com base no nome do participante.

Para colocar isso em prática, pensei primeiramente em utilizar uma serverless function da Vercel em conjunto com a biblioteca Puppeteer, criando uma instância headless do Chrome para tirar print de uma interface e retornar uma imagem PNG. Até cheguei a implementar essa funcionalidade, mas devido a um limite de tamanho de disco na Vercel não foi possível fazer deploy da aplicação 😪

Com isso, fui atrás de usar uma solução nativa através das Edge Functions da própria Vercel: Open Graph Image Generation. Essa biblioteca foi desenvolvida para a criação de imagens dinâmicas para Open Graph (aquelas miniaturas que aparecem ao compartilharmos algum link em uma rede social) de forma nativa, e depois de uma analisada na documentação, vi que era possível usar essa funcionalidade para esse contexto, e neste post vou detalhar todo esse processo 🎉

Instalação da biblioteca

Antes de mais nada, é necessário fazer a instalação da biblioteca em seu projeto Next:

yarn add @vercel/og

Por baixo dos panos essa biblioteca utiliza o Satori, outra biblioteca bem leve para a conversão de HTML/CSS (JSX nesse caso) em SVG e outros formatos de imagem, e por ser executado em ambiente Node, podemos utilizar as API routes do Next para essa finalidade.

Com isso, vamos criar um arquivo og.tsx dentro do diretório pages/api do nosso projeto com a seguinte estrutura:

import { ImageResponse } from '@vercel/og';
import { NextRequest } from 'next/server';

export const config = {
  runtime: 'edge',
};

export default async function handler(request: NextRequest) {
  try {
    return new ImageResponse(
      (
        <div
          style={{
            display: 'flex',
            height: 900,
            width: 1440,
            alignItems: 'center',
            justifyContent: 'center',
          }}
        >
          <p>Imagem customizada</p>
        </div>
      ),
      {
        width: 1440,
        height: 900,
      }
    );
  } catch (e: any) {
    console.log(`${e.message}`);
    return new Response(`Falha na geração da imagem :(`, {
      status: 500,
    });
  }
}

Perceba que o primeiro parâmetro da função ImageResponse é o código JSX que será convertido para SVG, e no segundo parâmetro, temos um objeto com algumas definições importantes, como largura e altura da imagem, e mais algumas que veremos mais adiante.

Se você rodar sua aplicação (yarn dev) e acessar http://localhost:3000/api/og, será exibida uma imagem em PNG.

A partir disso, podemos começar a definir o layout do nosso certificado. Infelizmente existem algumas limitações, como por exemplo, todas as <div> que tem children, devem receber display: flex ou display: none. Isso pode limitar alguns layouts mais avançados, mas neste caso, serviu perfeitamente para o propósito do layout em colunas. Além disso, todos os estilos CSS devem ser aplicados de forma inline, através de um objeto Javascript. Igual os incas 😶

Um ótimo e praticamente obrigatório parceiro para essa etapa é o OG Image Playground, que facilita o debug e a rápida visualização das mudanças feitas no código.

Começando a estilizar

Mesmo com algumas limitações, podemos otimizar nosso código para que o trabalho de estruturação do layout seja menos oneroso. Porém, a primeira etapa começou na exportação dos assets. Para facilitar o desenvolvimento, separei quatro arquivos estáticos em SVG: o background (sem os textos e as colunas), o logo do workshop, o texto “Certificamos que”, e todos os elementos (de forma agrupada) que aparecem embaixo do nome do participante. Tudo isso foi feito visando a facilidade de desenvolvimento, já que estamos em um ambiente controlado e não precisamos nos preocupar tanto com compatibilidade e melhores práticas 😉

Com isso feito, podemos criar o “esqueleto” da aplicação, com uma <div> para o fundo do certificado com um backgroundImage de um arquivo externo. Utilizei o arquivo de variáveis de ambiente do Next (.env) para criar uma chave chamada NEXT_PUBLIC_URL, que é simplesmente a url da aplicação, com base em cada ambiente (local/prod):

NEXT_PUBLIC_URL=http://localhost:3000
<div
  style={{
    backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/bg.svg)`,
    display: 'flex',
    height: 900,
    width: 1440,
    flexDirection: 'column',
    alignItems: 'center',
    justifyContent: 'center',
  }}
/>;

Depois, foi hora de abusar dos poderes do Flexbox para criarmos as doze colunas de fundo. Criei um componente Column para evitar a duplicação de código já que são doze elementos idênticos, com um gradiente de background:

function Column() {
  return (
    <div
      style={{
        background:
          'linear-gradient(0deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.07) 50%, rgba(255,255,255,0) 100%)',
        height: '100vh',
        flex: 1,
        margin: 10,
      }}
    ></div>
  );
}

A seguir, criei uma variável do tipo inteiro e com valor 12 chamada bootstrapColumnsLength (que será usada posteriormente), e utilizando spread operators, transformei em um array para usar o método map e criar doze elementos iguais. Todos esse elementos estariam dentro de uma <div> com position: absolute, para deixar as colunas abaixo dos elementos do certificado:

<div style={{ ...fullWidth, position: 'absolute' }}>
  {[...Array(bootstrapColumnsLength)].map((index: number) => (
    <Column key={index} />
  ))}
</div>;

Neste caso, buscando novamente evitar a duplicação desnecessária de códigos, criei um objeto chamado fullWidth com alguns estilos definidos que se repetiam, e novamente através dos spread operators repliquei esses estilos por algumas vezes:

const fullWidth = {
  display: 'flex',
  padding: '0 80px',
  width: '100%',
};

Posteriormente, adicionei os dois elementos do certificado que aparecem antes do nome do participante, como o logo e o texto:

<div
  style={{
    backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/logo.svg)`,
    width: 415,
    height: 125,
    marginBottom: 100,
  }}
/>
<div
  style={{
    backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/text.svg)`,
    width: 407,
    height: 51,
    marginBottom: 30,
  }}
/>

Vale citar que o flexDirection: column na <div> pai fez toda a diferença para a estilização desses elementos, visto que eles apareciam de forma sequencial (de cima para baixo) no layout do certificado.

Posteriormente vem a parte mais “complexa” do certificado. Ainda sem se preocupar com o cálculo do tamanho de cada coluna, criei uma “row” para as <divs> que de fato mostrariam o valor das colunas, com o valor (neste caso 8 e 4 respectivamente) ainda hard coded.

<div style={fullWidth}>
  <div
    style={{
      ...textBoxCodeText,
      flex: 8,
      flexBasis: '20px',
    }}
  >
    {`<div class="col-8">`}
  </div>
  <div
    style={{
      ...textBoxCodeText,
      flex: 4,
    }}
  >
    {`<div class="col-4">`}
  </div>
</div>;

Novamente criei um objeto chamado textBoxCodeText para reaproveitamento de estilos. Neste mesmo objeto há a referência de outro chamado …textBox (para os estilos que repetem), definido abaixo:

const textBox = {
  background: 'rgba(19, 17, 38, 0.6)',
  border: '1px solid #B93C9B',
  display: 'flex',
};

const textBoxCodeText = {
  ...textBox,
  color: '#B93C9B',
  margin: '0 10px',
  fontFamily: '"Source Code Pro"',
  fontSize: 20,
  alignItems: 'center',
  justifyContent: 'center',
  padding: '5px 0',
  position: 'relative' as 'relative', // sorry
};

Percebe que utilizei uma fonte customizada (Source Code Pro) para este elemento? Pois é, com essa biblioteca também conseguimos usar fontes personalizadas. O funcionamento é dessa forma:

const SourceCodePro = fetch(
  new URL('../../assets/fonts/SourceCodePro.ttf', import.meta.url)
).then((res) => res.arrayBuffer());

Você faz o fetch de um arquivo estático (ttf) local, e depois cria uma função assíncrona para importar a fonte:

const sourceFont = await SourceCodePro;

Posteriormente, você adiciona essa fonte no segundo parâmetro da função ImageResponse, dentro do array fonts, conforme exemplo abaixo:

fonts: [
  {
    data: sourceFont,
    name: 'Source Code Pro',
  },
],

Para este caso não foi necessário aplicar fontWeight ou fontStyle customizado, então apenas esses valores serviram.

Agora, os elementos mais importantes, a <div> do nome e a <div> dos emojis, junto com suas estilizações e fontes customizadas, usando os mesmos conceitos descritos anteriormente:

const dot = {
  background: '#D9D9D9',
  border: '1px solid #B93C9B',
  width: 8,
  height: 8,
  display: 'flex',
  position: 'absolute' as 'absolute',
};
<div style={{ ...fullWidth, marginBottom: 60 }}>
  <div
    style={{
      ...textBox,
      flex: 8,
      flexBasis: '20px',
      color: '#fff',
      margin: 10,
      fontFamily: '"Inter"',
      fontSize: 55,
    }}
  >
    <span style={{ ...dot, top: '-4px', left: '-4px' }} />
    <span style={{ ...dot, top: '-4px', right: '-4px' }} />
    <span style={{ ...dot, bottom: '-4px', left: '-4px' }} />
    <span style={{ ...dot, bottom: '-4px', right: '-4px' }} />
    <span
      style={{
        display: 'flex',
        padding: 10,
        width: '100%',
        letterSpacing: '-0.5px',
        textOverflow: 'ellipsis',
        overflow: 'hidden',
        whiteSpace: 'nowrap',
      }}
    >
      Jonathan Felipe
    </span>
  </div>
  <div
    style={{
      ...textBox,
      flex: 4,
      margin: 10,
      fontSize: 55,
      alignItems: 'center',
      justifyContent: 'center',
    }}
  >
    <span style={{ ...dot, top: '-4px', left: '-4px' }} />
    <span style={{ ...dot, top: '-4px', right: '-4px' }} />
    <span style={{ ...dot, bottom: '-4px', left: '-4px' }} />
    <span style={{ ...dot, bottom: '-4px', right: '-4px' }} />
    😊 😍 😂 🤣
  </div>
</div>;

Neste caso utilizei um <span> para criar um espaçamento interno no nome do participante sem que isso afetasse o cálculo do Flexbox da <div>, além de prevenir que o nome do participante quebre linha, usando text-overflow em conjunto do overflow: hidden e white-space: nowrap. Por fim, outros quatro para os dots que aparecem nos cantos do retângulo das colunas, simulando a interface do Figma.

Finalmente, o elemento final, que contempla em um único arquivo SVG os elementos que aparecem abaixo do nome do participante:

<div
  style={{
    backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/footer.svg)`,
    width: 1263,
    height: 211,
  }}
/>;

O código final, ainda sem estar dinâmico ficou desse jeito:

import { ImageResponse } from '@vercel/og';
import { NextRequest } from 'next/server';

export const config = {
  runtime: 'edge',
};

const Inter = fetch(
  new URL('../../assets/fonts/Inter.ttf', import.meta.url)
).then((res) => res.arrayBuffer());

const SourceCodePro = fetch(
  new URL('../../assets/fonts/SourceCodePro.ttf', import.meta.url)
).then((res) => res.arrayBuffer());

const fullWidth = {
  display: 'flex',
  padding: '0 80px',
  width: '100%',
};

const textBox = {
  background: 'rgba(19, 17, 38, 0.6)',
  border: '1px solid #B93C9B',
  display: 'flex',
};

const textBoxCodeText = {
  ...textBox,
  color: '#B93C9B',
  margin: '0 10px',
  fontFamily: '"Source Code Pro"',
  fontSize: 20,
  alignItems: 'center',
  justifyContent: 'center',
  padding: '5px 0',
  position: 'relative' as 'relative',
};

const dot = {
  background: '#D9D9D9',
  border: '1px solid #B93C9B',
  width: 8,
  height: 8,
  display: 'flex',
  position: 'absolute' as 'absolute',
};

export default async function handler(request: NextRequest) {
  const interFont = await Inter;
  const sourceFont = await SourceCodePro;

  const bootstrapColumnsLength = 12;

  function Column() {
    return (
      <div
        style={{
          background:
            'linear-gradient(0deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.07) 50%, rgba(255,255,255,0) 100%)',
          height: '100vh',
          flex: 1,
          margin: 10,
        }}
      ></div>
    );
  }

  try {
    return new ImageResponse(
      (
        <>
          <div
            style={{
              backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/bg.svg)`,
              display: 'flex',
              height: 900,
              width: 1440,
              flexDirection: 'column',
              alignItems: 'center',
              justifyContent: 'center',
            }}
          >
            <div style={{ ...fullWidth, position: 'absolute' }}>
              {[...Array(bootstrapColumnsLength)].map((index: number) => (
                <Column key={index} />
              ))}
            </div>

            <div
              style={{
                backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/logo.svg)`,
                width: 415,
                height: 125,
                marginBottom: 100,
              }}
            />
            <div
              style={{
                backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/text.svg)`,
                width: 407,
                height: 51,
                marginBottom: 30,
              }}
            />

            <div style={fullWidth}>
              <div
                style={{
                  ...textBoxCodeText,
                  flex: 8,
                  flexBasis: '20px',
                }}
              >
                {`<div class="col-8">`}
              </div>
              <div
                style={{
                  ...textBoxCodeText,
                  flex: 4,
                }}
              >
                {`<div class="col-4">`}
              </div>
            </div>

            <div style={{ ...fullWidth, marginBottom: 60 }}>
              <div
                style={{
                  ...textBox,
                  flex: 8,
                  flexBasis: '20px',
                  color: '#fff',
                  margin: 10,
                  fontFamily: '"Inter"',
                  fontSize: 55,
                }}
              >
                <span style={{ ...dot, top: '-4px', left: '-4px' }} />
                <span style={{ ...dot, top: '-4px', right: '-4px' }} />
                <span style={{ ...dot, bottom: '-4px', left: '-4px' }} />
                <span style={{ ...dot, bottom: '-4px', right: '-4px' }} />
                <span style={{ padding: 10 }}>Jonathan Felipe</span>
              </div>
              <div
                style={{
                  ...textBox,
                  flex: 4,
                  margin: 10,
                  fontSize: 55,
                  alignItems: 'center',
                  justifyContent: 'center',
                }}
              >
                <span style={{ ...dot, top: '-4px', left: '-4px' }} />
                <span style={{ ...dot, top: '-4px', right: '-4px' }} />
                <span style={{ ...dot, bottom: '-4px', left: '-4px' }} />
                <span style={{ ...dot, bottom: '-4px', right: '-4px' }} />
                😊 😍 😂 🤣
              </div>
            </div>

            <div
              style={{
                backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/footer.svg)`,
                width: 1263,
                height: 211,
              }}
            />
          </div>
        </>
      ),
      {
        width: 1440,
        height: 900,
        emoji: 'fluent',
        fonts: [
          {
            data: interFont,
            name: 'Inter',
          },
          {
            data: sourceFont,
            name: 'Source Code Pro',
          },
        ],
      }
    );
  } catch (e: any) {
    console.log(`${e.message}`);
    return new Response(`Failed to generate the image`, {
      status: 500,
    });
  }
}

Deixando o certificado dinâmico

Agora é hora de usar uma facilidade do Next para pegarmos alguns parâmetros via query string, através do parâmetro request do nosso handler.

Para isolarmos regra de negócio e todos os cálculos dessa aplicação do nosso front-end, vamos precisar de apenas dois parâmetros: name (nome do participante), e nameSize (um valor em pixels sendo o tamanho (width) do nome do participante). Posteriormente explicarei como isso foi feito.

Sendo assim, podemos pegar esses dois parâmetros da seguinte forma:

const { searchParams } = new URL(request.url);

const name = searchParams.get('name') || '';
const nameSize = searchParams.get('size') || '';

Com isso, podemos fazer o primeiro cálculo, que é basicamente pegar a largura em px do nome do participante e dividir pela largura de cada coluna do nosso grid (que neste caso é 90px). Criei uma variável com o nome bootstrapColumnWidth para deixar o código mais legível:

const bootstrapColumnWidth = 90;
const nameColumns = Math.ceil(+nameSize / bootstrapColumnWidth);

Acima basicamente trazemos o valor arredondado desse cálculo com o Math.ceil. Também usamos um “+” junto da variável nameSize para convertemos essa variável de string para number de forma prática. Dica profissa 😎

Com isso, a variável nameColumns armazena o tamanho da coluna necessária de acordo com o tamanho do nome do participante.

Com isso fica muito simples descobrir o tamanho da coluna dos emojis, é simplesmente subtrair o tamanho do coluna de nome pelo valor total de colunas:

const emojisColumns = bootstrapColumnsLength - nameColumns;

Com esses valores podemos partir para o cálculo mais complexo. Embora o Flexbox resolva grande parte dos nossos problemas, foi necessário um flexBasis diferente para cada tamanho de coluna:

  • Se a coluna tem flex 7, o flexBasis é 10;
  • Se a coluna tem flex 8, o flexBasis é 20;
  • Se a coluna tem flex 9, o flexBasis é 40;
  • Se a coluna tem flex 10, o flexBasis é 80;

Como os nomes dos participantes sempre ocuparão ao menos quatro colunas, os casos acima resolveriam quase todos os cenários possíveis. Faltava apenas um, onde o tamanho das colunas seria o mesmo. Nesse caso, não era necessário definir um flexBasis.

Decidi então criar uma função que recebe dois números por parâmetro (tamanho da coluna nome e tamanho da coluna emojis), e retorna um array com dois objetos de estilo, com flex e flexBasis calculados com base nos cenários descritos anteriormente. Esse foi o código final da função:

const flexColumns = (nameColumns: number, emojisColumns: number) => {
  let flexName: FlexObject = { flex: nameColumns, flexBasis: 0 };
  let flexEmojis: FlexObject = { flex: emojisColumns, flexBasis: 0 };

  const calcBasis = (number: number) => {
    switch (number) {
      case 7:
        return 10;
      case 8:
        return 20;
      case 9:
        return 40;
      default:
        return 0;
    }
  };

  if (nameColumns === emojisColumns) {
    flexName.flexBasis = 0;
    flexEmojis.flexBasis = 0;

    return [flexName, flexEmojis];
  }

  flexName.flexBasis =
    nameColumns > emojisColumns ? `${calcBasis(nameColumns)}px` : 0;
  flexEmojis.flexBasis =
    emojisColumns > nameColumns ? `${calcBasis(emojisColumns)}px` : 0;

  return [flexName, flexEmojis];
};

E um ponto importante foi limitar o tamanho máximo para a coluna do nome do participante para 9, já que se fosse superior a isso, o layout da coluna dos emojis quebraria. Sendo assim, fiz um if simples antes do código da função acima para limitar o tamanho da coluna de nome:

if (nameColumns > 9) {
  nameColumns = 9;
  emojisColumns = 3;
}

Com essa função (enorme) pronta, era só remover os códigos hard coded para os novos que seriam calculados com base nos parâmetros recebidos pela url.

<div style={fullWidth}>
  <div
    style={{
      ...textBoxCodeText,
      ...flexColumns(nameColumns, emojisColumns)[0],
    }}
  >
    {`<div class="col-${nameColumns}">`}
  </div>
  <div
    style={{
      ...textBoxCodeText,
      ...flexColumns(nameColumns, emojisColumns)[1],
    }}
  >
    {`<div class="col-${emojisColumns}">`}
  </div>
</div>

<div style={{ ...fullWidth, marginBottom: 60 }}>
  <div
    style={{
      ...textBox,
      ...flexColumns(nameColumns, emojisColumns)[0],
      color: '#fff',
      margin: 10,
      fontFamily: '"Inter"',
      fontSize: 55,
    }}
  >
    <span style={{ ...dot, top: '-4px', left: '-4px' }} />
    <span style={{ ...dot, top: '-4px', right: '-4px' }} />
    <span style={{ ...dot, bottom: '-4px', left: '-4px' }} />
    <span style={{ ...dot, bottom: '-4px', right: '-4px' }} />
    <span style={{ padding: 10 }}>{name}</span>
  </div>
  <div
    style={{
      ...textBox,
      ...flexColumns(nameColumns, emojisColumns)[1],
      margin: 10,
      fontSize: 55,
      alignItems: 'center',
      justifyContent: 'center',
    }}
  >
    <span style={{ ...dot, top: '-4px', left: '-4px' }} />
    <span style={{ ...dot, top: '-4px', right: '-4px' }} />
    <span style={{ ...dot, bottom: '-4px', left: '-4px' }} />
    <span style={{ ...dot, bottom: '-4px', right: '-4px' }} />
      😊 😍 😂 🤣
  </div>
</div>

E agora? Acabou? Ainda não. 😶

Faltava inserir emojis de forma aleatória. Decidi que a quantidade de emojis seria a mesma do tamanho da coluna… de emojis, hehe. Também escolhi alguns emojis específicos que combinam com um certificado de programação.

Confesso que aqui pedi uma ajudinha para o ChatGPT criar um código bonitão. A resposta foi algo que eu já imaginava, uma string com os emojis personalizados, e um for para criar uma lista com base no tamanho da coluna respectiva. Utilizei o .concat apenas para criar um espaçamento visual no certificado gerado.

const happyEmojis = '😊 😍 😂 🤣 😁 😆 😜 😝 🤩 😜 🤑 ⚡ ✨ 🚀';
const emojiArray = happyEmojis.split(' ');

function getRandomIndex() {
  return Math.floor(Math.random() * emojiArray.length);
}

let emojis = '';
for (let i = 0; i < emojisColumns; i++) {
  emojis += emojiArray[getRandomIndex()].concat(' ');
}

O código final ficou assim:

import { ImageResponse } from '@vercel/og';
import { NextRequest } from 'next/server';

type FlexObject = {
  flex: number;
  flexBasis: string | 0;
};

export const config = {
  runtime: 'edge',
};

const Inter = fetch(
  new URL('../../assets/fonts/Inter.ttf', import.meta.url)
).then((res) => res.arrayBuffer());

const SourceCodePro = fetch(
  new URL('../../assets/fonts/SourceCodePro.ttf', import.meta.url)
).then((res) => res.arrayBuffer());

const fullWidth = {
  display: 'flex',
  padding: '0 80px',
  width: '100%',
};

const textBox = {
  background: 'rgba(19, 17, 38, 0.6)',
  border: '1px solid #B93C9B',
  display: 'flex',
};

const textBoxCodeText = {
  ...textBox,
  color: '#B93C9B',
  margin: '0 10px',
  fontFamily: '"Source Code Pro"',
  fontSize: 20,
  alignItems: 'center',
  justifyContent: 'center',
  padding: '5px 0',
  position: 'relative' as 'relative',
};

const dot = {
  background: '#D9D9D9',
  border: '1px solid #B93C9B',
  width: 8,
  height: 8,
  display: 'flex',
  position: 'absolute' as 'absolute',
};

export default async function handler(request: NextRequest) {
  const interFont = await Inter;
  const sourceFont = await SourceCodePro;

  const { searchParams } = new URL(request.url);

  const name = searchParams.get('name') || '';
  const nameSize = searchParams.get('size') || '';
  const bootstrapColumnsLength = 12;
  const bootstrapColumnWidth = 90;
  let nameColumns = Math.ceil(+nameSize / bootstrapColumnWidth);
  let emojisColumns = bootstrapColumnsLength - nameColumns;

  if (nameColumns > 9) {
    nameColumns = 9;
    emojisColumns = 3;
  }

  const flexColumns = (nameColumns: number, emojisColumns: number) => {
    let flexName: FlexObject = { flex: nameColumns, flexBasis: 0 };
    let flexEmojis: FlexObject = { flex: emojisColumns, flexBasis: 0 };

    const calcBasis = (number: number) => {
      switch (number) {
        case 7:
          return 10;
        case 8:
          return 20;
        case 9:
          return 40;
        default:
          return 0;
      }
    };

    if (nameColumns === emojisColumns) {
      flexName.flexBasis = 0;
      flexEmojis.flexBasis = 0;

      return [flexName, flexEmojis];
    }

    flexName.flexBasis =
      nameColumns > emojisColumns ? `${calcBasis(nameColumns)}px` : 0;
    flexEmojis.flexBasis =
      emojisColumns > nameColumns ? `${calcBasis(emojisColumns)}px` : 0;

    return [flexName, flexEmojis];
  };

  const happyEmojis = '😊 😍 😂 🤣 😁 😆 😜 😝 🤩 😜 🤑 ⚡ ✨ 🚀';
  const emojiArray = happyEmojis.split(' ');

  function getRandomIndex() {
    return Math.floor(Math.random() * emojiArray.length);
  }

  let emojis = '';
  for (let i = 0; i < emojisColumns; i++) {
    emojis += emojiArray[getRandomIndex()].concat(' ');
  }

  function Column() {
    return (
      <div
        style={{
          background:
            'linear-gradient(0deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.07) 50%, rgba(255,255,255,0) 100%)',
          height: '100vh',
          flex: 1,
          margin: 10,
        }}
      ></div>
    );
  }

  try {
    return new ImageResponse(
      (
        <>
          <div
            style={{
              backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/bg.svg)`,
              display: 'flex',
              height: 900,
              width: 1440,
              flexDirection: 'column',
              alignItems: 'center',
              justifyContent: 'center',
            }}
          >
            <div style={{ ...fullWidth, position: 'absolute' }}>
              {[...Array(bootstrapColumnsLength)].map((index: number) => (
                <Column key={index} />
              ))}
            </div>

            <div
              style={{
                backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/logo.svg)`,
                width: 415,
                height: 125,
                marginBottom: 100,
              }}
            />
            <div
              style={{
                backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/text.svg)`,
                width: 407,
                height: 51,
                marginBottom: 30,
              }}
            />

            <div style={fullWidth}>
              <div
                style={{
                  ...textBoxCodeText,
                  ...flexColumns(nameColumns, emojisColumns)[0],
                }}
              >
                {`<div class="col-${nameColumns}">`}
              </div>
              <div
                style={{
                  ...textBoxCodeText,
                  ...flexColumns(nameColumns, emojisColumns)[1],
                }}
              >
                {`<div class="col-${emojisColumns}">`}
              </div>
            </div>

            <div style={{ ...fullWidth, marginBottom: 60 }}>
              <div
                style={{
                  ...textBox,
                  ...flexColumns(nameColumns, emojisColumns)[0],
                  color: '#fff',
                  margin: 10,
                  fontFamily: '"Inter"',
                  fontSize: 55,
                }}
              >
                <span style={{ ...dot, top: '-4px', left: '-4px' }} />
                <span style={{ ...dot, top: '-4px', right: '-4px' }} />
                <span style={{ ...dot, bottom: '-4px', left: '-4px' }} />
                <span style={{ ...dot, bottom: '-4px', right: '-4px' }} />
                <span
                  style={{
                    display: 'flex',
                    padding: 10,
                    width: '100%',
                    letterSpacing: '-0.5px',
                    textOverflow: 'ellipsis',
                    overflow: 'hidden',
                    whiteSpace: 'nowrap',
                  }}
                >
                  {name}
                </span>
              </div>
              <div
                style={{
                  ...textBox,
                  ...flexColumns(nameColumns, emojisColumns)[1],
                  margin: 10,
                  fontSize: 55,
                  alignItems: 'center',
                  justifyContent: 'center',
                }}
              >
                <span style={{ ...dot, top: '-4px', left: '-4px' }} />
                <span style={{ ...dot, top: '-4px', right: '-4px' }} />
                <span style={{ ...dot, bottom: '-4px', left: '-4px' }} />
                <span style={{ ...dot, bottom: '-4px', right: '-4px' }} />
                {emojis}
              </div>
            </div>

            <div
              style={{
                backgroundImage: `url(${process.env.NEXT_PUBLIC_URL}/footer.svg)`,
                width: 1263,
                height: 211,
              }}
            />
          </div>
        </>
      ),
      {
        width: 1440,
        height: 900,
        emoji: 'fluent',
        fonts: [
          {
            data: interFont,
            name: 'Inter',
          },
          {
            data: sourceFont,
            name: 'Source Code Pro',
          },
        ],
      }
    );
  } catch (e: any) {
    console.log(`${e.message}`);
    return new Response(`Falha ao gerar a imagem :(`, {
      status: 500,
    });
  }
}

Com isso, nossa API já estava pronta para ser usada no nosso front-end.

Gerando o certificado

Aqui não vou entrar muito em detalhes sobre o código do formulário para capturar o nome do participante porque não é o foco do tutorial. Mas basicamente criei uma <div> oculta no DOM que recebia o nome do participante com os mesmos estilos (font-family e font-size) do certificado, e com isso, era só pegar o tamanho em pixels dessa <div> através do JavaScript (clientWidth).

Depois era basicamente fazer o fetch na API criada acima com os parâmetros necessários, e converter a resposta para um Blob.

O código para essa função ficou da seguinte forma, sendo form.name o nome do participante:

const certificate = useCallback(async () => {
  const nameSize = textPreviewRef.current?.clientWidth || 0;

  const response = await fetch(
    `${process.env.NEXT_PUBLIC_URL}/api/og?name=${form.name}&size=${nameSize}`
  );

  const image = await response.arrayBuffer();
  const url = window.URL.createObjectURL(new Blob([image]));

  return url;
}, [form.name]);

E no envio do formulário, eu chamava essa função, criava um elemento <a> no DOM e simulava um clique para forçar o download do arquivo:

const link = document.createElement('a');
link.href = await certificate();
link.setAttribute('download', 'certificado.png');
document.body.appendChild(link);
link.click();

De brinde, utilizei duas bibliotecas maravilhosas (sonner e use-sound) para exibir feedbacks para o usuário, através de avisos em formato toast e sons baseados no programa Dança Gatinho do Rodrigo Faro. Sim. Sou desses. 🤣

A aplicação final pode ser vista aqui: https://certificado-workshop.vercel.app/ (digite “Jonathan Felipe” no campo nome) para que o certificado seja gerado.

E caso você seja daqueles que preferem o código ao invés do tutorial, o código-fonte completo está no meu Github :)

E é isso! Esse tutorial ficou bastante extenso mas espero ter detalhado o suficiente para explicar cada linha do código. Se tiver alguma dúvida, pode me chamar no Linkedin 😀