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.
To generate an OG image, we'll be using these npm packages.
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
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
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.
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.
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.
https://phiilu.com/generate-open-graph-images-for-your-static-next-js-site
https://github.com/vercel/og-image
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.