Dynamic Open Graph Image Generator with Layer0, Next.js, TailwindCSS, Chrome AWS Lambda and Puppeteer-Core
Rishi Raj Jain
Posted on June 8, 2022
What's an Open Graph Image?
Consider sharing a link on Twitter, LinkedIn or Slack. The descriptive images you see about the article even before you open them is because of Open Graph Image Tag (i.e. og:image
inside your html).
An Example
Images below show how a Tweet, LinkedIn Post or Slack message will look like when the link https://dev.to/digitalplayer1125/conditional-basic-authorization-using-the-platform-layer0-54bi
is shared.
But how?
This all happens through the og:image
or twitter:image:src
tag inside the <head>
tag of your html.
<meta property="og:image" content="https://res.cloudinary.com/practicaldev/image/fetch/s--bp_68opi--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s4u11hj7netzt0695wzj.png" />
OR
<meta name="twitter:image:src" content="https://res.cloudinary.com/practicaldev/image/fetch/s--bp_68opi--/c_imagga_scale,f_auto,fl_progressive,h_500,q_auto,w_1000/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s4u11hj7netzt0695wzj.png">
Automating Dynamic Previews
You might wanna ask: But Rishi, do I need to design each image, upload somewhere and then add it to my blog? Oh it'd be amazing if you could do some magic and do that over the air?
Well, yes you can do that! This is what the rest of the blog is gonna be about, using Next.js, chrome-aws-lambda and puppeteer-core to create an app deployed on Layer0 to create pages (and cache them), and then serve screenshots (one generated, cached forever) of them as the dynamic previews.
End Product aka Output's Example
generates the image below:
Why such a big link?
The link can be broken into following parts:
-
https://rishi-raj-jain-html-og-image-default.layer0-limelight.link/api
: An express endpoint created using API Routes with Next.js - Query Parameters:
-
title
: The descriptive text, visible majorly in the generated image -
image
: Link to an image that'll be embedded inside the generated image -
mode
: A string either intrue
orfalse
to toggle the dark mode in the generated image
-
NOTE: While using such links inside HTML, it's important to know that while dynamically creating this link, one has to make use of encodeURIComponent() to ensure that link to the image, spaces in title, etc. get properly encoded to be received as is by the express endpoint.
For example,
const title= 'Something'
const image= 'https://images.unsplash.com/photo-1644982647869-e1337f992828?ixlib=rb-1.2.1&ixid=MnwxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1035&q=80'
const mode= 'true'
// The correct link to the generated image shall be:
const previewImageLink= `https://rishi-raj-jain-html-og-image-default.layer0-limelight.link/api?title=encodeURIComponent{title}&image=encodeURIComponent{image}&mode=encodeURIComponent{mode}`
Creating Dynamic Previews App
Step 1: Initialise a Next.js App
npx create-next-app@latest dynamic-image-generator
Step 2: Install TailwindCSS with Next.js
Follow the update guide on https://tailwindcss.com/docs/guides/nextjs
Step 3: Create a page that'll be dynamic to query parameters
We'll hit /dynamic_blogs with the same query parameters as received by /api to take screenshot of. Let's create the page inside your next app.
// File: pages/dynamic_blogs.js
// Destructuring title, image and mode from the query (sent as props to this component)
const Blogs = ({ title, image, mode }) => {
return (
<div className={`flex flex-row px-10 items-center justify-center h-screen w-screen ${mode === 'true' ? 'bg-gray-900' : 'bg-gray-100'}`}>
<div className="px-10 py-0 m-0 w-4/5 h-4/5 flex flex-col">
<h5 className="text-2xl text-gray-500">Checkout this article</h5>
<h1 className={`mt-2 text-4xl sm:text-6xl leading-none font-extrabold tracking-tight ${mode === 'true' ? 'text-white' : 'text-gray-900'}`}>{title}</h1>
<div className="flex flex-row items-start mt-auto">
<img src="https://rishi.app/static/favicon-image.jpg" className="rounded-full" style={{ width: '120px', height: '120px' }} />
<div className="ml-5 flex flex-col">
<h6 className={`font-bold text-4xl ${mode === 'true' ? 'text-gray-300' : 'text-gray-500'}`}>Rishi Raj Jain</h6>
<p className="mt-3 text-2xl text-gray-500">Wanna take everyone along in this web development journey by learning and giving back async</p>
</div>
</div>
</div>
<div className="px-10 py-0 m-0 w-2/5 h-4/5">
<img src={image} className="object-cover h-full" />
</div>
</div>
)
}
export default Blogs
/// Receive the query parameters on the server-side
// Read more on queries with getServerSideProps at:
// https://github.com/vercel/next.js/discussions/13309
export async function getServerSideProps({ query }) {
return {
props: { ...query },
}
}
Step 4: Install puppeteer-core and chrome-aws-lambda
We'll be using these packages to open the link /dynamic_blogs with query parameters and then return screenshot from the API endpoint created in the next step.
npm i puppeteer-core chrome-aws-lambda
Step 5: Create an API Route with Next.js
Do read the comments inside the file.
// File: pages/api/index.js
// This is accessible from the deployed link (say, Y.com) as y.com/api?queryparametershere
import core from 'puppeteer-core'
import chromium from 'chrome-aws-lambda'
export default async function handler(req, res) {
// Only allow POST to the given route
if (req.method === 'GET') {
const { title, mode, image, width = 1400, height = 720 } = req.query
// Launching chrome with puppeteer-core
// https://github.com/puppeteer/puppeteer/issues/3543#issuecomment-438835878
const browser = await core.launch({
args: chromium.args,
defaultViewport: chromium.defaultViewport,
executablePath: await chromium.executablePath,
headless: chromium.headless,
ignoreHTTPSErrors: true,
})
// Create a page
const page = await browser.newPage()
// Define the dimensions of the page
await page.setViewport({ width: parseInt(width), height: parseInt(height) })
// Load the /dynamic_blogs with the given query paramters
// Don't forget to encode them!
// req.headers.host allows to obtain the deployed link as is, hence this app can be deployed anywhere
// This allows us to take advantage of Layer0 caching to serve the /dynamic_blogs pages faster to this .goto() call
await page.goto(`https://${req.headers.host}/dynamic_blogs?title=${encodeURIComponent(title)}&image=${encodeURIComponent(image)}&mode=${encodeURIComponent(mode)}`)
// On average, place an image that is fast to load.
// Falling back to 5 seconds timeout where image might take longer to load.
await page.waitForTimeout(5000)
// Take screenshot of the body of the page, that is the content
const content = await page.$('body')
const imageBuffer = await content.screenshot({ omitBackground: true })
await page.close()
await browser.close()
res.setHeader('Cache-Control', 'public, immutable, no-transform, s-maxage=31536000, max-age=31536000')
res.setHeader('Content-Type', 'image/png')
res.send(imageBuffer)
res.status(200)
return
}
// Any other method than GET results in a ERROR 400.
res.status(400).json({ message: 'Invalid method.' })
return
}
Step 6: Install Layer0 CLI
npm i -g @layer0/cli
Step 7: Integrate Layer0 with Next.js
To wrap Layer0 over your Next.js app, run:
layer0 init # 0 init
Modify next.config.js
to opt-in target:'server'
with the latest Next.js version. This is how the config will look like:
// File: next.config.js
const { withLayer0, withServiceWorker } = require('@layer0/next/config')
module.exports = withLayer0(
withServiceWorker({
target: 'server',
compress: true,
layer0SourceMaps: true,
disableLayer0DevTools: true,
})
)
Step 8: Cache both the dynamic_blogs page and the API Route
With Layer0's caching, you can overcome the long times to generate the dynamic pages and the API response again, rather cache them as long as you want. As these are just images, that'll be cached separately with each new query parameter value, you can cache them for a good long year.
Same can be achieved by modifying routes.js as follows:
const { nextRoutes } = require('@layer0/next')
const { Router } = require('@layer0/core/router')
module.exports = new Router()
.match('/service-worker.js', ({ serviceWorker }) => {
return serviceWorker('.next/static/service-worker.js')
})
// Caching will be unique to each unique query param
// /dynamic_blogs?title=Some will be cached for a year
// /dynamic_blogs?title=Other will be cached for a year
// But each will serve pages that contain the respective titles, and not the same.
.match('/dynamic_blogs', ({ cache }) => {
cache({
browser: {
maxAgeSeconds: 0,
serviceWorkerSeconds: 31536000,
},
edge: {
maxAgeSeconds: 31536000,
forcePrivateCaching: true,
},
})
})
// Similar to dynamic_blogs, caching will be unique to each unique query param
.match('/api', ({ cache }) => {
cache({
browser: {
maxAgeSeconds: 0,
serviceWorkerSeconds: 31536000,
},
edge: {
maxAgeSeconds: 31536000,
forcePrivateCaching: true,
},
})
})
.use(nextRoutes)
Step 9: Deploy to Layer0
layer0 deploy # 0 deploy
OR
At the end, you shall see something like this:
Understanding the architecture
Step 10: Test
Open the deployed URL, and append /api?title=Incremental%20Static%20Generation&image=https://images.pexels.com/photos/12079516/pexels-photo-12079516.jpeg?cs=srgb&dl=pexels-hoài-nam-12079516.jpg&fm=jpg
at the end of it to see the magic happen!
That's it folks!
I hope this was helpful. Hit me up for any doubt on rishi18304@iiitd.ac.in.
Posted on June 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.