Static, automated social images with NextJS

Static, automated social images with NextJS

Generate per-page Open Graph images easily, at build time. Create an image template and turn it into images right from getStaticProps.

The "social images" are linked to pages via Open Graph metadata and used by social networks to display rich content. For example, if I share demo-project-nextjs-devto.vercel.app on Slack I get:

Share a link on Slack

The white-text-blue-background image above is probably the first thing that catches the eye.

As great opportunities to grab attention, social images should be carefully crafted. Often they are not. No wonder. Creating such images for every single page is plain boring. A classic strategy is to create a one-size-fits-all image with a generic title and company's logo, and use it everywhere...

David good enough

However, we can do way better. This tutorial explains how to do it right with NextJS, by automating the boring part while leaving us only the fun bits.

In this first part, we focus on static sites, ie. getStaticProps. We will explore server side rendering later.

By the end of your reading, you will have a NextJS app, ready to be deployed to Vercel, with two pages. Each page will have its own social image.

Get ready, create your app!

yarn create next-app social-demo

No multiple images: a single image template

What we are afraid of is the idea of creating dozens of similar images manually. We don't want to do this.

However, there is something fun to do: define how these images should look like, once for all. Plus, we are going to make it a web design task, with HTML and CSS.

Enter your app and create a new image template using the Resoc image template dev kit:

cd social-demo
npx itdk init resoc-template

This command creates a new image template and opens it in your browser.

Default image template

What you see here is not an image but a template. Some elements are fixed, like the logo in the bottom left corner. Some others are designed to change from an image to another, like the title or the photo. Edit the values in the right panel to check for yourself.

This template is a good start but we won't use it as is. First, we need to decide of the fields. For this demo, let's rely on something pages already have: a title and a description.

Open social-demo/resoc-template/resoc.manifest.json, which describes the template. Replace its content with:

{
  "partials": {
    "content": "./content.html.mustache",
    "styles": "./styles.css.mustache"
  },
  "parameters": [
    {
      "name": "title",
      "type": "text",
      "demoValue": "A picture is worth a thousand words"
    },
    {
      "name": "description",
      "type": "text",
      "demoValue": "And so much to say!"
    }
  ]
}

Go back to your browser. The form in the right panel now has the two fields we put in the manifest:

New parameters

Oh, and we broke the existing template in the process, but that's okay. We want to write our own anyway.

Edit social-demo/resoc-template/content.html.mustache. Here you find regular HTML, with a little bit of Mustache, a templating system. For example, <h1>{{ title }}</h1> will actually become <h1>Whatever</h1> when generating an image where the title is, well, "Whatever". Our template is fairly simple:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400&display=swap" rel="stylesheet">

<div class="wrapper">
  <h1 id="title">
    {{ title }}
  </h1>
  <div id="footer">
    <h2 id="description">
      {{ description }}
    </h2>
    <img id="logo" src="logo.png" />
  </div>
</div>

<script src="textFit.js"></script>
<script>
  window.onload = function() {
    textFit(document.getElementById('title'),
      { maxFontSize: 500, multiLine: true });
  };
</script>

We use textFit, a handy JS utility to make text fits its container.

We need CSS in social-demo/resoc-template/styles.css.mustache to make the template attractive:

.wrapper {
  font-family: Roboto;

  padding: 4vw;

  background: rgb(7,68,144);
  background: linear-gradient(50deg, rgba(7,68,144,1) 0%, rgba(0,123,255,1) 100%);

  color: white;

  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 10vh;
}

#title {
  flex: 1.62;
  font-size: 0.01rem; /* Rely on textFit to find the right size */
  height: 100%;
}

#footer {
  display: flex;
  align-items: center;
  gap: 10vh;

  font-size: 6vh;
}

#description {
  flex: 1 1 auto;

  margin: 0;

  text-overflow: ellipsis;
  overflow: hidden;
  white-space: nowrap;
}

#logo {
  height: 10vh;
}

Also, why not replace social-demo/resoc-template/logo.png with something else?

Now our template looks great:

Final template

We want to make sure our template does not break with large chunks of text. Let's type a lot of text to check how it behaves:

Huge text

Alright! The title font size decreases as we type thanks to textFit, and the description overflow is hidden with ellipsis. Our template is ready.

Better meta management

NextJS boilerplate puts page metadata, such as title and favicon, in social-demo/pages/index.js. This is okay for a first shot, but a real app won't force each page to declare these markups. Instead, they should be defined once for all.

We are going to move meta logic to MyApp and pass title and description via pageProps. Edit social-demo/pages/_app.js:

import Head from 'next/head'
import '../styles/globals.css'

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Head>
        <title>{pageProps.title}</title>
        <meta name="description" content={pageProps.description} />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <Component {...pageProps} />
    </>
  )
}

export default MyApp

We now change social-demo/pages/index.js accordingly. We remove the <Head> markup and content near the beginning of the file, along with the next/Head import. We also add a new getStaticProps at the bottom of the file. And we drop most of the demo content for clarity:

import styles from '../styles/Home.module.css'

export default function Home() {
  return (
    <div className={styles.container}>
      No need for content...
    </div>
  )
}

export async function getStaticProps(context) {
  return {
    props: {
      title: "Automated social images!",
      description: "Look ma, no hands"
    }
  }
};

Start the app:

yarn run dev

Visit http://localhost:3000/ and note the homepage title in your tab. It says "Automated social images!".

Time for automation!

Automate image creation

We are now going to use our image template to create as many images as there are pages.

First, create directory social-demo/public/open-graph, where images will be stored. When using Git, these files don't need to be committed. So we should add this repository to .gitignore and commit an empty social-demo/public/open-graph/.keep file to make sure the directory is always here.

We need two packages to turn our template into images:

yarn add --dev @resoc/core @resoc/create-img

Resoc uses Puppeteer and Chromium to convert HTML to images. Everything will take place at build time, thus the --dev. We won't hear about these packages once our app is deployed.

We create social-demo/lib/social-image.js to take advantage of these utilities:

import { compileLocalTemplate } from '@resoc/create-img'
import { FacebookOpenGraph } from '@resoc/core'
import path from 'path'

const socialImage = async (title, description, baseName) => {
  const ogImage = await compileLocalTemplate(
    'resoc-template/resoc.manifest.json', {
      title,
      description
    },
    FacebookOpenGraph,
    `public/open-graph/${baseName}.jpg`
  );

  return {
    title,
    description,
    ogImage: path.basename(ogImage)
  }
};

export default socialImage;

This is were most of the magic happens. We declare socialImage, which takes the title and description as parameters, along with a name for the image. We call compileLocalTemplate to turn the template into an image, passing it the template itself, the title and description, the output image resolution (FacebookOpenGraph) and an output file name. Finally, we return values which will be used to populate pageProps.

Let's call socialImage. In social-demo/pages/index.js, update getStaticProps:

export async function getStaticProps(context) {
  return {
    props: {
      ...(await socialImage(
        'Automated social images!',
        'Look ma, no hands',
        'homepage'
      ))
    }
  }
};

In addition to the title and description, MyApp receives a third prop named ogImage, returned by socialImage. Edit social-demo/pages/_app.js to take it into account:

import Head from 'next/head'
import { FacebookOpenGraph } from '@resoc/core'
import '../styles/globals.css'

function MyApp({ Component, pageProps }) {
  return (
    <>
      <Head>
        <title>{pageProps.title}</title>
        <meta name="description" content={pageProps.description} />
        <link rel="icon" href="/favicon.ico" />

        <meta property="og:title" content={pageProps.title} />
        <meta property="og:description" content={pageProps.description} />
        <meta property="og:image" content={`/open-graph/${pageProps.ogImage}`} />
        <meta property="og:image:width" content={FacebookOpenGraph.width} />
        <meta property="og:image:height" content={FacebookOpenGraph.height} />
      </Head>

      <Component {...pageProps} />
    </>
  )
}

export default MyApp

The Open Graph markups we've just added are straightforward. og:title and og:description are similar to the existing title and description tags. og:image:width and og:image:height define the image resolution. And og:image is the URL of the generated image.

Playtime! Restart the app:

yarn run dev

Visit the homepage and inspect the social-demo/public/open-graph directory. It contains an image named homepage.jpg:

Homepage image

In this image, we find the title and description of the homepage.

Let's create a second page for the sake of having multiple images generated for us. Populate social-demo/pages/another-page.js with:

import styles from '../styles/Home.module.css'
import socialImage from '../lib/social-image'

export default function Home() {
  return (
    <div className={styles.container}>
      Just another page...
    </div>
  )
}

export async function getStaticProps(context) {
  return {
    props: {
      ...(await socialImage(
        'Just another page',
        'Nothing fancy, really',
        'another-page'
      ))
    }
  }
};

Visit http://localhost:3000/another-page and look at social-demo/public/open-graph: a new image with a different content!

However, there is an issue we need to fix.

No time to waste: use the cache!

We could ship our app as it. Images will be created at build time, as they should.

However, if you refresh your homepage repeatedly, you will notice that each access takes a couple of seconds. This is because the social image of the page is always re-generated.

Although we can wait a few moments while experimenting with social images, this is definitely not something we want to hear about while coding the rest of our app. Waiting more than a few milliseconds just breaks the development experience.

We fix this by leveraging caching. To do so, edit socialImage in social-demo/lib/social-image.js:

import { compileLocalTemplate } from '@resoc/create-img'
import { FacebookOpenGraph } from '@resoc/core'
import path from 'path'

const socialImage = async (title, description, baseName) => {
  const ogImage = await compileLocalTemplate(
    'resoc-template/resoc.manifest.json', {
      title,
      description
    },
    FacebookOpenGraph,
    // Use the image hash as a suffix
    `public/open-graph/${baseName}-{{ hash }}.jpg`,
    // Enable cache
    { cache: true }
  );

  return {
    title,
    description,
    ogImage: path.basename(ogImage)
  }
};

export default socialImage;

We pass the options { cache: true } to compileLocalTemplate to enable cache. We also change the output image name to public/open-graph/${baseName}-{{ hash }}.jpg. compileLocalTemplate now generates a hash based on the template itself and the title and description. It then injects this hash in the file name, thanks to the {{ hash }} in the name. If such a file already exists, it means we already generated this image and there is no need to do it again. Else, the image is created. compileLocalTemplate also returns the file name so we can use it in the the Open Graph og:image meta.

Refresh the homepage a few times. The first refresh still takes a few seconds. But the next ones are as fast as usual. Also check social-demo/public/open-graph: it now contains an image named homepage-462b92c2.jpg or something similar.

The result

You can deploy this app to Vercel.

Or you can skip this step and check the app I wrote while writing this article: demo-project-nextjs-devto.vercel.app.

To see the social images in action, you can visit the Facebook debugger or share the link via Facebook, LinkedIn, etc.

Conclusion

We've just made our social images from lame to great, while turning the creation process from dull to fun!

Although this demo was simple, we can easily find ways to improve it. In particular, we could add more data to the images. Like the author's name and profile picture (when there is one), the keywords already populated by the SEO team in the corresponding meta markup, the featured image, etc.

We used the Resoc Image Template Development Kit and other components. I'm creating this suite to make social images creation a bliss. If you followed this article, I would love to get your feedback!

We covered only static generation. Server-side rendering is another topic we will explore in a future article.