DEV Community

Rishi Raj Jain
Rishi Raj Jain

Posted on

Dynamic Open Graph Image Generator with Layer0, Next.js, TailwindCSS, Chrome AWS Lambda and Puppeteer-Core

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.

Open Graph Image on Twitter

Fig.1 - Article when shared on Twitter

Open Graph Image on LinkedIn

Fig.2 - Article when shared on LinkedIn

Open Graph Image on Slack

Fig.3 - Article when shared on Slack

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" />
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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

The link https://rishi-raj-jain-html-og-image-default.layer0-limelight.link/api?title=Case%20Study%3A%20How%20Nike.com%20can%20leverage%20Layer0%20to%20improve%20their%20First%20Page%20Loads%20and%20Navigation%20upto%20~80%25%2C%20acing%20Largest%20Contentful%20Paint.&image=https%3A%2F%2Fimages.pexels.com%2Fphotos%2F1456706%2Fpexels-photo-1456706.jpeg%3Fcs%3Dsrgb%26dl%3Dpexels-ray-piedra-1456706.jpg%26fm%3Djpg&mode=true

generates the image below:

OG Image Example

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 in true or false 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}`
Enter fullscreen mode Exit fullscreen mode

Creating Dynamic Previews App

Step 1: Initialise a Next.js App

npx create-next-app@latest dynamic-image-generator
Enter fullscreen mode Exit fullscreen mode

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 },
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Install Layer0 CLI

npm i -g @layer0/cli
Enter fullscreen mode Exit fullscreen mode

Step 7: Integrate Layer0 with Next.js

To wrap Layer0 over your Next.js app, run:

layer0 init # 0 init
Enter fullscreen mode Exit fullscreen mode

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,
  })
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Step 9: Deploy to Layer0

layer0 deploy # 0 deploy
Enter fullscreen mode Exit fullscreen mode

OR

Deploy with Layer0

At the end, you shall see something like this:

Deployment Successful Layer0

Understanding the architecture
OG-Image 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.

Top comments (2)

Collapse
 
imselmon profile image
Salman Shaikh

Have you tried this with vercel/og

Collapse
 
reeshee profile image
Rishi Raj Jain • Edited

Hey, yes, I've checked out vercel/og, and it's a super simplified version of this, hats off to the Vercel team! But that requires you to use edge runtime with Vercel afaik. Via my method, you can ship to any serverless hosting and cache it however you like.