DEV Community

JanaSundar
JanaSundar

Posted on • Originally published at janasundar.dev on

How to generate dynamic open graph images

This post was originally published at this blog

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 in Twitter, Discord, or other applications. A fancy card image / link preview is displayed and that image is called an open graph image / 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.

Screenshot 2021-10-16 at 3.14.28 PM.png

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

Screenshot 2021-10-17 at 4.41.13 PM.png

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>');
  }
}

Enter fullscreen mode Exit fullscreen mode

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;
};


Enter fullscreen mode Exit fullscreen mode

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;
    }

    `;
};

Enter fullscreen mode Exit fullscreen mode

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>`;
};

Enter fullscreen mode Exit fullscreen mode

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>');
  }

Enter fullscreen mode Exit fullscreen mode

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. If you have any doubts, you can reach me at mailtojanasundar@gmail.com. Thanks for reading ✌️ and if you enjoyed this article, share it with your friends and colleagues.


Discussion (0)