Dynamically Produce Social Link Preview Images

These days I'm working on a small side project called the "Health Informatics Cafe" - https://hicafe.co, it's a simple website that me and my friends use to keep track of conferences and digital health events happening online and around the world.

I was always interested in the GitHub twitter link previews, where they generate the preview image for each link dynamically, I wanted to do the same for the HICafe. I could have maintained a template image, and create one for each event manually, and put it on the server. This would have been bit tedious and not very efficient. So I was looking for ways different ways to generate the link preview dynamically.

Trying out SVG

The easiest way to do this is by using SVGs, since SVG files are basically XML files, I could easily parse them and add text dynamically. But unfortunately not all platforms support SVG, so I had to look for alternative option.

But, all hope is still not lost, I figured out the easiest thing to do, would be to follow the step that I have mentioned above that is dynamically generate an SVG and then rather than putting the SVG in the website, I would convert it to a PNG file on the go and then include that in the HICafe's meta tag.

Converting the SVG to PNG

So first I created an SVG file, and I used some templating inspirations by adding {{ title }} and {{ datetime }} as template string within the SVG file.

Then I wrote a simple HTTP server in Bun, that would accept the title and the date time fields as url parameters, and replace the {{ title }} and the {{ datetime }} fields within the SVG template.

Then I used the resvg-js javascript library (https://github.com/thx/resvg-js) to generate the PNG image, and serve the generated PNG as a buffer without saving the PNG to the disk.

Few important things

  • You can't use query parameters because that would be something that social media bots would reject, so always go for route parameters instead.
❌ Dont - https://hicafe.co/og?title=foo&dateime=bar
✅ Do - https://hicafe.co/foo/bar.png
  • Try to add an extension to the image, and make sure you return a content type as png/image in the response header
  • The size of the image should be 1200px in width, and 600px in height
  • One thing that I learned the hard-way, or took sometime to figure out was the fact that the image URL not contain any spaces, you can URL encode the filename so the spaces will be converted to %20 characters. This will make the crawlers, especially WhatsApp to detect the image
❌ Dont - https://hicafe.co/foo/bar baz.png
✅ Do - https://hicafe.co/foo/bar%20baz.png
  • The image tag <meta og:image should appear within the first 300kb of the website
  • Use the LinkedIn post inspector https://www.linkedin.com/post-inspector/ and the Facebook Graph Debugger to see the previews of your the link to get an idea how it will look when you share the link of these social media platforms
  • You can also get a huge performance boost by providing a font file to resvg-js library, than using system fonts. You can do this by downloading your preferred font though Google fonts, and uploading it to your server and providing it as an option in resvg-js

The whole setup didn't take me an hour to setup, and I really enjoyed doing this. It took me a day to figure out why WhatsApp is not parsing my image, because LinkedIn and Facebook parsed it with spaces in the URL, but for some reason Whatsapp requires the URL to be encoded. and this is something that's not documented in any website.

The overall code looks something similar to this, it's customized to the SVG that I'm using, so some customization might be required on your-end to make things work

import { join } from "path";
import { Resvg } from "@resvg/resvg-js";
import { gzipSync } from "zlib";

async function main(param1: string | null, param2: string | null) {
  const opts = {
    font: {
      fontFiles: ["./Manrope/static//Manrope-Bold.ttf"],
      loadSystemFonts: false, // It will be faster to disable loading system fonts.
      defaultFontFamily: "Manrope",
    },
  };
  const svg = Bun.file(join(__dirname, "./test.svg"));
  const fileString = await svg.text();
  param1 = decodeURIComponent(param1 || "");
  param2 = decodeURIComponent(param2 || "");
  let rounds = 1;
  if (param1 && param1.length > 35) {
    rounds = 2;
  } else if (param1 && param1.length > 70) {
    rounds = 3;
  } else if (param1 && param1.length > 105) {
    rounds = 4;
  }

  let tspan = `<tspan x="10%" dy="1.2em">{{line}}</tspan>`;
  let lines = "";
  if (param1 == null) param1 = "Hello";
  for (let i = 0; i + 1 < 4; i++) {
    lines =
      lines + tspan.replace("{{line}}", param1.slice(i * 35, (i + 1) * 35));
  }

  const replaceContent = fileString
    .replace("{{title}}", lines)
    .replace("{{datetime}}", param2 || "World")
    .replace("{{datetimeY}}", 40 + 15 * (rounds - 1) + "%");
  const resvg = new Resvg(replaceContent, opts);
  const pngData = resvg.render();
  const pngBuffer = pngData.asPng();
  return pngBuffer;
}

export default {
  port: 3000,
  async fetch(req: Request) {
    // Create a new URL object
    const url = new URL(req.url);

    const paths = url.pathname.split("/");

    if (paths[3] === undefined) {
      return new Response("{}", {
        headers: {
          "Content-Type": "application/json",
        },
      });
    }

    // Access query parameters using `searchParams`
    const param1 = decodeURI(paths[2] || "");
    const param2 = decodeURI(paths[3].split(".")[0] || "");

    const svg = await main(param1, param2);
    const gzippedImage = gzipSync(new Uint8Array(svg));
    return new Response(gzippedImage, {
      headers: {
        "Content-Type": "image/png",
        "Content-Encoding": "gzip",
      },
    });
  },
};

So finally if you are reading this post, and interested in Digital Health I invite you to see https://hicafe.co, a curated list of digital health events happening around the world.

Rukshan Ranatunge

Rukshan Ranatunge

Switzerland