How-tos & Guides
13 min read

How to automatically generate social share images for your blog

Using TypeScript and Puppeteer, we'll walk through how to automatically generate custom social share images for your blog posts using data from your CMS.

Antonello Zanini
Published September 19, 2022

Encouraging your readers to share your blog posts on social channels like Reddit, LinkedIn, Twitter, and Facebook is a great way to increase your audience. But with so much content being shown to social media users, how do you ensure your content cuts through the noise?

One way to increase engagement is with high quality imagery. A recent study showed that Facebook posts that contained images have 2.3x more engagement compared to posts without images.

In this article, we’ll show you how to set up your blog to automatically generate images that are (1) optimized for social media sharing and (2) personalized for each blog post on your site.

How do Open Graph and Twitter Cards work?

Social sites are heavily reliant on user-generated content, so it makes sense that they want to make sharing content on their platform as easy as possible. When you share a link on a site like Reddit or Facebook, they automatically extract key information from the web page, like its title, domain, and representative image, so that the user sharing the link doesn’t have to enter that information manually.

For example, here’s what LinkedIn automatically grabs and displays for a different Reflect blog post:

In order for the right content to be displayed on social sites when a link is shared, you’ll need to implement some basic HTML tags on each page of your site using the protocols described below.

Open Graph Protocol

Most popular social sites, including LinkedIn, Reddit, Facebook, Pinterest, and WhatsApp, use a protocol called Open Graph (OGP) to determine what information to display when a URL is shared on their platform. While it might sound complex, the Open Graph protocol is actually very simple. It’s just a set of tags added to an HTML page which define some important information about the page, including its title, what type of content it contains, and what image to display when it’s shared. This information is defined in <meta> tags that have property attributes which describe different aspects of the content of the page. Here’s an example of what an og:image property would look like, which is the property used to define the image associated with the page:

<meta property="og:image" content="https://www.example.com/my-image.jpg" />

Open Graph Protocol supports defining other image-related properties. This includes the og:image:secure_url attribute, which is meant to be used by sites that requires HTTPS, as well as the og:image:width and og:image:height attributes, which define the dimensions of the og:image. In practice, just defining the og:image property is all you need to get an image to display properly, so long as the dimensions of the image you’re specifying fall within the recommended dimensions of the various social platforms. (Note: We cover the recommended dimensions for several popular social sites in the Frequently Asked Questions section at the end of this article).

Twitter Cards

While Twitter does support crawling and parsing Open Graph tags, it also created its own metadata protocol called Twitter Cards which supports most of the same information as Open Graph. To specify what image should be displayed on Twitter when a link is shared, you’d define the twitter:image property like in the example below:

<meta name="twitter:image" content="https://www.example.com/my-image.jpg" />

Though you could get away with just defining Open Graph image. We’d recommend implementing both Open Graph and Twitter Cards to ensure links to your site are displayed optimally everywhere.

Why generate a dynamic image for each blog post?

Now that you know how to implement Open Graph and Twitter Card tags, you might be tempted to use the same static image such as a logo for each blog post and call it day. This approach may be marginally better than not defining an image at all, but it’s still not the best solution. Look at the example below:

The two links posted on LinkedIn refer to two different blog posts. However, it is not so easy to tell at a glance. Plus, an image that’s completely unrelated to the topic could actually deter users from clicking on the link.

Instead, we’d recommend using a site like Unsplash to find an engaging piece of imagery that relates to each blog post. Unsplash images are free to use, so long as you provide the proper attribution back to the content creator’s Unsplash page.

However, we can go even one step further and generate a customized social share image that contains additional information about the blog post, like in the example below:

Writing a script to generate social share images using TypeScript and Puppeteer

Let’s walk through the code used to generate the image above. Since this script runs on Node.js, you’ll first want to ensure that Node and npm are installed on your local machine.

The complete code to generate this image is in the following GitHub repository. To run this code ourselves, we’ll star by cloning the repository and installing the necessary dependencies:

git clone https://github.com/runreflect/social-image-generator.git
cd social-image-generator
npm i

1. Libraries used by the social image generator script

Before digging into how the script works, let’s first see what libraries it depends on.

As you can see in the package.json file, the social image generator TypeScript script depends only on the following two libraries:

To understand why the script requires gray-matter, you first need to understand what a front-matter is. A front-matter is a set of meta variables that are used to describe information about a document, such as a title, author, description, and publication date.

Front-matters are generally defined at the beginning of a file, such as a Markdown document. A front-matter generally starts and ends with three dashes (---).

This is what a sample front-matter looks like:

---
title: Creating social share images using Typescript and Puppeteer
description: An amazing description
publisheddate: January 1, 2022
author: The Reflect Team
authoravatar: example-avatar.png
hero: example-hero.jpg
---

Similarly, to understand how node-html-to-image works, you need to learn more about Puppeteer. Puppeteer is a Node.js library that provides a high-level API to control Chrome or Chromium over the DevTools Protocol. In detail, Puppeteer can run Chrome or Chromium in headless mode by default, providing headless browser capabilities.

In other words, Puppeteer allows you to use the native features of a browser in your Node.js backend. This is how node-html-to-image manages to convert HTML into images.

2. Creating the template for your social share images

First, let’s see how to generate an HTML template for your Open Graph/Twitter Card images.

This is what the image HTML template generator function looks like:

// src/index.ts

async function generateTemplate(frontMatter: matter.GrayMatterFile<Buffer>): Promise<string> {
  // getting the path of the current directory
  const currentDir = process.cwd();
  // getting the reading times in minutes from the markdown content of the blog post
  const readingTimeInMinutes = calculateReadingTime(frontMatter.content);

  // extracting data from the front-matter
  const title = frontMatter.data.title;
  const author = frontMatter.data.author;
  const authorAvatar = frontMatter.data.authoravatar;
  const hero = frontMatter.data.hero;

  // Since Puppeteer canno render locally hosted images, you need to to generate base64 images
  // instead of referencing them as "file://" URLs
  const logoImage = await generateBase64ImageUrl(`${currentDir}/assets/images/reflect-logo-dark.png`);
  const heroImage = await generateBase64ImageUrl(`${currentDir}/assets/images/${hero}`);
  const authorAvatarImage = await generateBase64ImageUrl(`${currentDir}/assets/images/${authorAvatar}`);

  // The HTML / CSS template used for our social share images. Feel free to customize this however you'd like!
  return `
        <!DOCTYPE html>
        <style>
        html, body {
            width: 540px;
            max-width: 540px;
            height: 450px;
            max-height: 450px;
            margin: 0;
            padding: 0;
            overflow: none;
            color: white;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
        }
        body {
            background-image:
              linear-gradient(0deg, rgba(0,0,0, .51), rgba(0,0,0, .27), rgba(0,0,0,.27), rgba(0,0,0, .51)),
              url('${heroImage}');
            background-size: cover;
            background-position: center center;
            background-repeat: no-repeat;
            font-feature-settings: 'kern';
            -webkit-font-smoothing: antialiased;
        }
        h1 {
            font-size: 42px;
            color: #FFFFFF;
            line-height: 52px;
            text-shadow: 0 2px 1px rgba(0,0,0,0.70);
        }
        .author {
            font-size: 18px;
            font-weight: 500;
            color: #FFFFFF;
            line-height: 1.5;
        }
        .minutes {
            opacity: 0.88;
            font-weight: 500;
            font-size: 18px;
            color: #DEDEDE;
        }
        .flexbox-col {
            display: flex !important;
            flex-direction: column;
            flex-wrap: nowrap;
        }

        .flexbox-row {
            display: flex !important;
        }
        .stretch {
            flex-grow: 1;
        }
        .justify-space-between { justify-content: space-between; }
        .justify-end { justify-content: flex-end; }
        </style>
        <html>
          <body class="flexbox-col justify-space-between">
            <div class="stretch" style="margin-top: 24px; margin-left: 24px; margin-right: 24px;">
              <div class="flexbox-row">
                <img src="${authorAvatarImage}" class="mr-1" style="margin-right: 8px; border-radius: 50%; width: 48px; border: 2px solid white;" />
                <div class="flexbox-col">
                  <div class="author">${author}</div>
                  <div class="minutes">${readingTimeInMinutes} min read</div>
                </div>
              </div>
              <h1>${title}</h1>
            </div>
            <div class="flexbox-row justify-end" style="margin-left: 24px; margin-right: 24px;">
              <img src="${logoImage}" style="margin-bottom: 24px; width: 181px;" />
            </div>
          </body>
        </html>
      `;
}

As you can see, the generateTemplate() function requires a matter.GrayMatterFile<Buffer> object containing the front-matter info read with matter. Then, it uses this parameter to extract the info required to build the image HTML template.

In detail, it gets the reading time with the following calculateReadingTime() function:

// src/index.ts
function calculateReadingTime(content: string): number {
  // attempting to replicate Hugo's method for calculating "Reading Time"
  // see -> https://github.com/gohugoio/hugo/blob/35fa192838ecfa244335fca957e55d3956a48665/hugolib/page__per_output.go#L659
  const wordCount = content.split(" ").length;
  return Math.max(Math.round(wordCount / 213), 1);
}

Note that while this script was developed for Hugo, and other CMSes may use a different approach to generating read-time. The [reading-time](https://www.npmjs.com/package/reading-time) would be a more robust and accurate way to calcuate the reading time of an article vs. the hard-coded approach used in the script.

Also, generateTemplate() requires the following generateBase64ImageUrl() function:

// src/utils/http-utils.ts
import { toBase64 } from "./file-utils";

export async function generateBase64ImageUrl(filename: string): Promise<string> {
  const image = await toBase64(filename);
  return "data:image/jpeg;base64," + image;
}

generateBase64ImageUrl() simply converts an image file into its Base64 Image URL representation through the toBase64() function below:

// src/utils/file-utils.ts
import fs from "fs";

export async function toBase64(filename: string) {
  // retrieving the file associated with the filename
  // received as a parameter
  const file = await fs.promises.readFile(filename);
  // generating the Base64 representation of
  // the file retrieved
  return Buffer.from(file).toString("base64");
}

Note that the string returned by generateBase64ImageUrl() can be used as the src attribute of an <img> HTML tag.

3. Putting it all together

This is what the social image generator TypeScript script looks like, with a few parts omitted for brevity. The full script is available here:

import nodeHtmlToImage from "node-html-to-image";
import matter from "gray-matter";
import fs from "fs";
import { generateBase64ImageUrl } from "./utils/http-utils";
import { chunk } from "./utils/string-utils";
import { checkAndCreateDirectory } from "./utils/file-utils";

interface IOptions {
  contentDir: string;
  outputDir: string;
}

const MAX_CONCURRENT_PUPPETEER_PROCS = 5;

async function main(options: IOptions) {
  // extracting the front-matter file names
  const filenames = (await fs.promises.readdir(options.contentDir)).filter((filename) => filename !== "_index.md");

  console.log(`Generating social cards for (${filenames.length}) articles.`);

  // processing the front-matters in chunks rather than all at once
  // because each image generation process call spin up
  // a new Puppeteer process, namely a new Chrome/Chromium instance
  const groups: string[][] = chunk(filenames, MAX_CONCURRENT_PUPPETEER_PROCS);

  // simultaneously trasforming all front-matter files
  // contained in each group into an image
  for (const group of groups) {
    await Promise.all(
      group.map(async (filename) => {
        return await generateSocialCardForArticle(filename, options);
      })
    );
  }

  console.log(`Social cards have been successfully written to: ${options.outputDir}`);
}

async function generateSocialCardForArticle(filename: string, options: IOptions) {
  // generating the file name of the output image
  const outputFilename = filename.split(".")[0] + ".png";

  // getting the front-matter file
  const file = await fs.promises.readFile(`${options.contentDir}/${filename}`);

  // extracting the front-matter info
  const frontMatter = matter(file);

  // if not present, creating the output directory
  checkAndCreateDirectory(options.outputDir);

  // generating the image HTML template
  const html = await generateTemplate(frontMatter);

  // trasforming the
  return nodeHtmlToImage({
    output: options.outputDir + "/" + outputFilename,
    html,
  });
}

async function generateTemplate(frontMatter: matter.GrayMatterFile<Buffer>): Promise<string> {
  // ... omitted for brevity
}

function calculateReadingTime(content: string): number {
  // ... omitted for brevity
}

// verifying that the command to execute the script was called correctly
if (process.argv.length !== 4) {
  console.log("Usage: node index.js <content-directory> <output-directory>");
  process.exit(1);
}

// extracting the parameter from the execution command
const contentDir = process.argv[2];
const outputDir = process.argv[3];

// calling the main function to generate the social images
main({
  contentDir,
  outputDir,
});

This script works by reading the contentDir and outputDir arguments passed by the CLI. The first parameter should contain the path of the input directory containing all your front-matter files. The second parameter should contain the path of the output directory that will store the images generated by the script.

In the main() function, the script splits the array containing all front-matter file names into an array of subarrays. Each element of this new array contains up to MAX_CONCURRENT_PUPPETEER_PROCS elements. This splitting process is done by the following chunk() function:

// src/utils/string-utils.ts

export function chunk(arr: any[], chunkSize: number): any[] {
  // see -> https://stackoverflow.com/questions/8495687/split-array-into-chunks
  if (chunkSize <= 0) {
    throw "Invalid chunk size";
  }

  var R = [];
  for (var i = 0, len = arr.length; i < len; i += chunkSize) R.push(arr.slice(i, i + chunkSize));

  return R;
}

Therefore, only a limited amount of front-matters will simultaneously be transformed into images. This is a good compromise to get the required results faster than in working in series, but without overloading the system.

Then, each front-matter file name is passed to generateSocialCardForArticle(). Here, the Open Graph image is automatically generated by transforming the image HTML template created by the aforementioned generateTemplate() function into an image file with the node-html-to-image library.

Note that some lines of code were omitted for brevity. You can take a look at the entire script here.

4. Using the social image generator script

It is now time to learn how to launch the social image generator script. Place your front-matter Markdown files in the <contentDir> parameter folder. Similarly, place all your source image files in the <contentDir>/assets/image folder.

Retrieve great hero images for free to use as the background of your generated images with services like Unsplash. Just make sure to provide proper attribution to the author of the Unsplash image when using the generated image.

Now, let’s learn how to use the TypeScript script. First, build it with the following command:

    npm run build

This will launch the command below:

    tsc --build --clean && tsc --project tsconfig.json

As you can see from the tsconfig.json file below, the built application will be written in the dist directory.

{
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules"],
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "declaration": true
  }
}

Now, move your <contentDir> folder inside the dist directory.

Enter the dist directory and launch the script with the command below:

    cd dist
    node index.js assets/content output

Note that assets/content represents the <contentDir> parameter and output the <outputDir> parameter.

The script will log the following info in the terminal:

    Generating social cards for (1) articles.
    Social cards have been successfully written to: output

Then, in the output folder inside the dist directory, you will see all the generated images.

Et voilà! You just learned how to automatically generate original Open Graph/Twitter Card images for your blog posts from Markdown front-matters.

5. Associating the generated images to your blog posts

You can now use the generated images in your CMS platform or headless CMS solution like Hugo, Strapi, Ghost, or DatoCMS. All you have to do is upload them as Open Graph and Twitter Card images in your SEO plugin/section of your CMS.

Frequently Asked Questions

Twitter recommends an aspect ratio of 2:1 and minimum dimensions of 300x157 pixels.

Each social networks has its own recommendations for the optimal Open Graph image size:

Conclusion

In this article you learned what Open Graph/Twitter Card images are, why they are so important when it comes to sharing links on social, and how to automatically generate high-quality Open Graph images for your blog posts through a TypeScript script based on Puppeteer. In particular, you saw how this image generator script works in a step-by-step explanation. Also, you learned how can use it to produce original social share images and use them in the <meta> tags of your HTML documents.

Get started with Reflect today

Create your first test in 2 minutes, no installation or setup required. Accelerate your testing efforts with fast and maintainable test suites without writing a line of code.

Copyright © 2022 Reflect Software Inc. All Rights Reserved.