How to generate dynamic open graph images

6 min readjavascript

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.

  • puppeteer-core

  • chrome-aws-lambda

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 👇

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

JS
import { getContent, getCss } from '../../utils/getContent';
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) {
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' });
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
JS
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

getCss
JS
export const getAbsoluteURL = (path) => {
const baseURL = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000';
return baseURL + path;
};
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
JS
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

screenshot
JS
const page = await getPage();
await page.setViewport({ width: 1200, height: 630 });
await page.setContent(html, { waitUntil: 'networkidle2' });
await page.evaluateHandle('document.fonts.ready');
const buffer = await page.screenshot({ type: 'png' });
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. 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.

githubtwittercontactRSS

Built with next js and deployed on vercel