How to Create Dynamic Open Graph Images

Written by jana | Published 2021/10/20
Tech Story Tags: javascript | open-graph | open-graph-images | puppeteer | aws-lambda | aws | web-development | javascript-tutorial | web-monetization

TLDRIn this article, we will see how to generate dynamic open graph images. We'll be using these npm packages to generate an OG image. We need a function that takes a screenshot of our content and passes it as a response. This can be achieved as follows: getChalk, getCss, getPage, getPfPage and chrome from 'puppeteer-core' The final output will be an image of an open graph image or OG image. We'll see how our output looks like.via the TL;DR App

Introduction

In this article, we will see how to generate dynamic open graph images. You might be wondering what an open graph image is? Whenever you share a link on Twitter, Discord, or other applications. A fancy card image/link preview is displayed, and that image is called an open graph image or an OG image.

Requirements

To generate an OG image, we'll be using these npm packages.

Installing Requirements

Do you have yarn? If not, then install it and run the below command or else use npm

yarn add puppeteer-core chrome-aws-lambda

Creating your OG Image

Before creating this function, If you're planning to deploy this project in vercel. I'll suggest everyone create a separate project. Otherwise, we may face this issue 👇

If we integrate this function with our existing project and deploy it in vercel. There is a chance that we might get an error message that the serverless function dependency package compressed size exceeds 50Mb, and then it stops the deployment process.

To get an idea, we'll see how our final output looks like

https://og-image.janasundar.dev/api/ogimage?title=How i built my blog with Next Js&tags=javascript,jsx,nextjs

From the above URL and image, we understand that the dynamic data are passed as the query parameters to generate an image.

So now we need a function that takes a screenshot of our content and passes it as a response. This can be achieved as follows

import chalk from 'chalk';
import { getContent, getCss } from '../../utils/getContent';
import { getPage } from '../../utils/getPage';
import puppeteer from 'puppeteer-core';
import chrome from 'chrome-aws-lambda';

let page;

const isDev = process.env.NODE_ENV === 'development';

const exePath =
  process.platform === 'win32'
    ? 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
    : process.platform === 'linux'
    ? '/usr/bin/google-chrome'
    : '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';

export const getPage = async () => {
  if (page) {
    return page;
  }

  const getOptions = async () => {
    let options;
    if (isDev) {
      options = {
        args: [],
        executablePath: exePath,
        headless: true,
      };
    } else {
      options = {
        args: chrome.args,
        executablePath: await chrome.executablePath,
        headless: chrome.headless,
      };
    }

    return options;
  };

  const options = await getOptions();
  const browser = await puppeteer.launch({
    ...options,
  });
  page = await browser.newPage();

  return page;
};


export default async function handler(req, res) {
  console.info(chalk.cyan('info'), ` - Generating Opengraph images`);

  const { title, tags, handle, logo, debug, fontFamily, background, fontFamilyUrl } = req.query;

  const css = getCss(fontFamily, fontFamilyUrl, background);
  const html = getContent(tags, title, handle, logo, css);

  if (debug === 'true') {
    res.setHeader('Content-Type', 'text/html');
    res.end(html);
    return;
  }

  try {
    const page = await getPage();
    await page.setViewport({ width: 1200, height: 630 });
    await page.setContent(html, { waitUntil: 'networkidle2', timeout: 15000 });

    await page.evaluateHandle('document.fonts.ready');

    const buffer = await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: 1200, height: 630 } });

    res.setHeader('Cache-Control', `public, immutable, no-transform, s-maxage=31536000, max-age=31536000`);
    res.setHeader('Content-Type', 'image/png');
    res.end(buffer);
  } catch (error) {
    console.error(error);

    res.statusCode = 500;
    res.setHeader('Content-Type', 'text/html');
    res.end('<h1>Internal Error</h1><p>Sorry, there was a problem</p>');
  }
}

Let's take a closer look at our code.

// getPage

import puppeteer from 'puppeteer-core';
import chrome from 'chrome-aws-lambda';

let page;

const isDev = process.env.NODE_ENV === 'development';

const exePath =
  process.platform === 'win32'
    ? 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
    : process.platform === 'linux'
    ? '/usr/bin/google-chrome'
    : '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';

export const getPage = async () => {
  if (page) {
    return page;
  }

  const getOptions = async () => {
    let options;
    if (isDev) {
      options = {
        args: [],
        executablePath: exePath,
        headless: true,
      };
    } else {
      options = {
        args: chrome.args,
        executablePath: await chrome.executablePath,
        headless: chrome.headless,
      };
    }

    return options;
  };

  const options = await getOptions();
  const browser = await puppeteer.launch({
    ...options,
  });
  page = await browser.newPage();

  return page;
};

Our getPage function launches the browser and gives us a reference to the browser page. To take a screenshot, we're using the puppeteer-core and chrome-aws-lambda package. If you don't know what puppeteer and chrome-aws-lambda are. Refer to the official docs link in the reference section.

Reference

export const getAbsoluteURL = (path) => {
  const baseURL = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000';
  return baseURL + path;
};

// getCss

export const getCss = (fontFamily, fontFamilyUrl, background) => {
  return `
    ${fontFamilyUrl ?? "@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600&display=fallback');"}

    * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    font-family: ${
      fontFamily ?? 'Nunito'
    }, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans','Helvetica Neue', sans-serif;
    color: white;
    }

    .container {
    width: 1200px;
    height: 630px;
    background: ${background ? background :  `url(${getAbsoluteURL('/ogbackground.svg')})`};
    padding:3rem;
    margin:0 auto;
    display: flex;
    flex-direction:column;
    }

    .content {
    padding: 3rem 5rem;
    display: flex;
    flex: 1;
    flex-direction: column;
    justify-content: space-between;
    }

    .title {
    display: flex;
    align-items: center;
    justify-content: center;
    max-width: 840px;
    flex: 1;
    margin:0 auto;
    text-align: center;
    }

    .title > h1 {
    font-size: 64px;
    line-height: 74px;
    font-weight: 600;
    font-style: normal;

    }

    .logo {
    justify-content: space-between;
    display: flex;
    align-items: center;
    padding: 1rem 3rem;
    }

    .tags {
    font-size: 1rem;
    display: flex;
    gap: 10px;
    justify-content: center;
    padding: 2rem 0;
    }

    .pill{
      background: #caa8ff33;
      color: white;
      padding: 0.25rem 1rem;
      border-radius: 50rem;
      text-transform: capitalize;
      box-shadow: 0 0 1rem rgba(0,0,0,0.1);
      font-weight: bold;
    }

    .handle{
      font-size: 24px;
      font-weight: 600;
    }

    `;
};

Our getCss function gets all the styles of our OG card. It'll take three optional parameters to generate CSS.

// getContent

export const getContent = (tags, title, handle, logo, css) => {
  return `
    <html>
    <meta charset="utf-8">
    <title>Generated Image</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
       ${css}
    </style>
    <body>
        <div class='container'>

        <div class="content">
            <div class="title"><h1>${title ?? 'Welcome to this site'}</h1></div>
            ${
              tags
                ? `<div class="tags">
                  ${tags
                    .split(',')
                    .map((tag) => {
                      return `<span key=${tag} class="pill">${tag}</span>`;
                    })
                    .join('')}
                </div>`
                : ''
            }

        </div>
        <div class="logo">
            <img src="${logo ?? getAbsoluteURL(`/logo.svg`)}" alt="logo" width="100px" height="100px" >
            <div class="handle">${handle ?? '@Jana__Sundar'}</div>
        </div>
        </div>
    </body>
    </html>`;
};

Our getContent function generate Html based on the CSS, title, twitter handle, logo, and tags.

Generally, the most recommended dimensions to generate an OG image is 1200 x 630. These are not the perfect values. Check this link to find the different recommendations.

Reference

try {
    const page = await getPage();
    await page.setViewport({ width: 1200, height: 630 });
    await page.setContent(html, { waitUntil: 'networkidle2', timeout: 15000 });

    await page.evaluateHandle('document.fonts.ready');

    const buffer = await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: 1200, height: 630 } });

    res.setHeader('Cache-Control', `public, immutable, no-transform, s-maxage=31536000, max-age=31536000`);
    res.setHeader('Content-Type', 'image/png');
    res.end(buffer);
  } catch (error) {
    console.error(error);

    res.statusCode = 500;
    res.setHeader('Content-Type', 'text/html');
    res.end('<h1>Internal Error</h1><p>Sorry, there was a problem</p>');
  }

Now, we need to pass the generated HTML to the set content function then take a screenshot by calling the screenshot function from puppeteer with filetype. Return the buffer value from the screenshot and send it as a response.

Reference

This project is open-source on GitHub. Have a closer look if you want.

Hopefully, you have learned how to generate a dynamic OG image. Thanks for reading ✌️ and if you enjoyed this article, share it with your friends and colleagues.


This article was first published here.


Written by jana | Senior Software Engineer @Cimpress | Full Stack Developer | Javascript Enthusiast
Published by HackerNoon on 2021/10/20