Generate dynamic Open Graph images using Netlify Edge Functions

by Sean C Davis

You just published a blog post that has the potential to be the best idea any developer has ever had, so you share it on whatever Twitter is called these days, but then the link doesn’t unfurl because there’s no Open Graph image.

Users scroll right on by your social message and that great idea fades away. Having preview images for a web page gives it an extra layer of polish when the page is shared on social platforms. But generating an image for every page can be tedious work.

Automation to the rescue!

#TL;DR

We’re going to dig into a method of automating the generation of Open Graph images for the pages of a site. We’ll explore how Edge Functions can generate these images for you on-demand, and how the content from your site can be use to populate the images.

This example (and its demo) demonstrates a template you could build once and apply it to all pages on your site.

Just deploy and play

If you’d prefer to just clone and deploy an example to explore, and come back for more explanation later, you can do that with a click of this button.

Deploy to Netlify

Okay, maybe an image won’t make or break the success of a post. But it will add a level of refinement to your web content, adding more clicks (at least that’s what ChatGPT told me).

And yet, adding those images is yet another tedious thing that slows down the publishing process.

#Images can be dynamically generated

But what if it didn’t have to be tedious? What if the images were automatically generated for every page on your site and all you had to do was check the image before publishing?

That’s one way we’ve streamlined our publishing process for this site. I’m going to show you how it works with a trimmed-down example.

#How to generate dynamic images with Edge Functions

Netlify Edge Functions are a powerful way to generate content upon request and serve the appropriate (dynamic) response from the edge (geographically close to the user).

To generate an image response for an Edge Function, we’ll use og_edge, a project from Matt Kane that is built on @vercel/og (which is built on satori). It’s designed to run in Deno, the runtime environment for Edge Functions.

There’s more to how all this works, but we can piece that together along the way. Let’s start building!

#Set up your project

We’re going to focus exclusively on image generation so that it’s easier to take and apply to your project. For that reason, we won’t work with a framework and don’t need much to get started.

#Starting from scratch

If you’re going to follow along, you can start from scratch with the following:

  • Basic package.json with http-server installed
  • public directory with a boilerplate index.html file
  • A dev script in package.json set to http-server --port 3000 ./public
  • Netlify CLI installed globally

#Install VS Code Deno recipes

If you’re not used to working in Deno and if you’re working in VS Code, you can use the vscode recipe to add the appropriate settings to VS Code. Run the following command in your terminal:

netlify recipes vscode

You’ll also want to add deno.path to .vscode/settings.json and have it set to the local path to the deno runtime.

In the end, your .vscode/settings.json should contain five deno properties:

{
"deno.enable": true,
"deno.enablePaths": ["netlify/edge-functions"],
"deno.unstable": true,
"deno.importMap": ".netlify/edge-functions-import-map.json",
"deno.path": "~/path/to/deno"
}

#Start development server

We can test Edge Functions locally using Netlify Dev. With the Netlify CLI installed globally, run this command:

ntl dev --command "yarn dev" --target-port 3000

This will open a new browser window and should serve the public/index.html file.

Note that yarn dev and 3000 should be set to the appropriate values for your project.

#Build a basic image generator

Because we’re working with Deno, we don’t have to install any dependencies to get started. However, if you prefer to work in TypeScript (which is what we’ve shown in these examples), you’ll want to install typescript and add a tsconfig.json file.

Once you have what you need, add the edge function to netlify/edge-functions/image-preview.tsx.

import type { Config, Context } from "@netlify/edge-functions";
import { ImageResponse } from "https://deno.land/x/og_edge/mod.ts";
import React from "https://esm.sh/react@18.2.0";
const STYLES = {
wrapper: {
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#ffffff",
},
};
export default async (request: Request, context: Context) => {
const page = {
title: "👋 Hello from Netlify 👋",
description: "This is a preview image dynamically generated by a Netlify Edge Function!",
};
return new ImageResponse(
(
<div style={STYLES.wrapper}>
<div>{page.title}</div>
<div>{page.description}</div>
</div>
),
{ width: 1200, height: 630 }
);
};
export const config: Config = { path: "/preview-image" };

This is about as simple as this process gets:

  • STYLES sets us up with an organized set of style rules ready to be expanded for the title and description.
  • The page data is hard-coded (for now).
  • We use ImageResponse from og_edge to return an image response whenever visiting /preview-image, which is specified by config.

The result is an unflattering image, but it works!

Unstyled generated OG image

#Style the image

Let’s step through a few different tasks that will help with styling the image.

#Add style rules to title and description

First, let’s add some CSS to the content by adding new rules to STYLES and using style attributes in the markup.

// imports ...
const STYLES = {
wrapper: {
// ...
},
title: {
padding: "0 48px",
marginTop: "164px",
fontSize: 80,
fontWeight: 700,
},
description: {
padding: "0 48px",
marginTop: "36px",
lineHeight: 1.35,
fontSize: 36,
fontWeight: 300,
},
};
export default async (request: Request, context: Context) => {
// page data ...
return new ImageResponse(
(
<div style={STYLES.wrapper}>
<div style={STYLES.title}>{page.title}</div>
<div style={STYLES.description}>{page.description}</div>
</div>
),
{ width: 1200, height: 630 }
);
};
// config ...

And now we have a bit more styling:

A little style in our generated OG image

#Add background and logo SVG images

I like to use SVG images to spruce up these images. That lets me do the work in a design program like Figma and then drop into the project as components.

Let’s start with the background image. Feel free to use any image you’d like. Or borrow from the example. In the example project, I added a file for the background image component at netlify/edge-functions/assets/BackgroundImage.tsx:

// netlify/edge-functions/assets/BackgroundImage.tsx
import React from "https://esm.sh/react@18.2.0";
export const BackgroundImage: React.FC = () => (
// SVG code goes here, with JSX syntax ...
)

And I added a logo file at netlify/edge-functions/assets/Logo.tsx:

// netlify/edge-functions/assets/Logo.tsx
import React from "https://esm.sh/react@18.2.0";
export const Logo: React.FC = () => (
// SVG code goes here, with JSX syntax ...
)

Then we can import those into the function and use them as JSX components:

import { Logo } from "./assets/Logo.tsx";
import { BackgroundImage } from "./assets/BackgroundImage.tsx";
// other imports ...
// STYLES ...
export default async (request: Request, context: Context) => {
// page data ...
return new ImageResponse(
(
<div style={STYLES.wrapper}>
<BackgroundImage />
<Logo />
<div style={STYLES.title}>{page.title}</div>
<div style={STYLES.description}>{page.description}</div>
</div>
),
{ width: 1200, height: 630 }
);
};
// config ...

And now it’s really starting to come together!

Some branded style in a generated OG image

#Add fonts to the image

Using custom fonts can be a tricky process. I recommend you follow the example exactly for this one and then veer off on your own when you have the hang of it. (There are gotchas listed at the end of this guide.)

First, add the fonts to your public directory (I put them in public/fonts). Here are the example fonts.

We’ll add the fonts into the function in four steps:

  • Define font attributes
  • Add font family to styles
  • Use a function to load the font data
  • Include the font data in the image response
// imports ...
// NEW: font attributes
const FONTS = [
{
name: "Pacaembu",
weight: 700,
style: "normal",
filePath: "pacaembu/PacaembuNetlify-Bold.woff",
},
{
name: "Pacaembu",
weight: 300,
style: "normal",
filePath: "pacaembu/PacaembuNetlify-Medium.woff",
},
];
const STYLES = {
wrapper: {
// NEW: Specify font family
fontFamily: "Pacaembu",
// other wrapper styles ...
},
// others styles ...
};
// NEW: Function to load font data
async function loadFonts(origin: string) {
return await Promise.all(
FONTS.map(async (font) => {
const { name, weight, style, filePath } = font;
const url = [origin, "fonts", filePath].join("/");
const fontFileResponse = await fetch(url);
const data = await fontFileResponse.arrayBuffer();
return { name, weight, style, data };
})
);
}
export default async (request: Request, context: Context) => {
// page data ...
// NEW: call the font loader to get the font data at runtime
const { origin } = new URL(request.url);
const fonts = await loadFonts(origin);
return new ImageResponse(
(
<div style={STYLES.wrapper}>
<BackgroundImage />
<Logo />
<div style={STYLES.title}>{page.title}</div>
<div style={STYLES.description}>{page.description}</div>
</div>
),
// NEW: include font data
{ width: 1200, height: 630, fonts }
);
};
// config ...

Refresh, and if everything was configured properly, you should see the new fonts in your image!

Custom fonts in a generated OG image

#Use dynamic content

Now we have the base in place and have added some styling. All that is left is making the page content dynamic so we can use this function for every page (or some predictable set of pages) on the site.

#Accessing sitemap data

There are several ways to be able to get the content you need to render the function. I’ve tried a few different approaches, but have found the most reliable and efficient approach to be caching sitemap content in a JSON file that can be served statically.

Your mileage may vary as a site scales, but at the time of writing this, we’re using a data cache that comes in at 15 KB (without minifying) and gets the job done.

You can experiment with methods that may work better for your project. However, be cautious about calling external API endpoints for every image request, which may put you at risk for hitting API limits, depending on the service you’re using and volume of content on the site.

#Mock sitemap content

For this example, I added a mock set data for 20 pages. (Thanks, ChatGPT!) Each page has title, description, and slug properties. We’ll use each of these in the function.

Drop the sitemap data in your public directory if following along —public/sitemap-data.json:

[
{
"title": "Edge Handlers Revolution",
"description": "Explore the impact of Edge Handlers in serverless.",
"slug": "edge-handlers-revolution"
},
{
"title": "Guide to Netlify Dev",
"description": "Explore Netlify Dev, a local dev tool.",
"slug": "guide-to-netlify-dev"
}
// more pages ...
]

#Load sitemap content

To load the dynamic content, we’ll replace our static page object with a call to fetch the sitemap data, put a catch to return 404 when the slug isn’t present in the sitemap, and then adjust the Edge Function’s route to be dynamic:

// imports ...
// fonts and styles ...
// NEW: function to fetch sitemap and find the page data
async function getPageFromSitemap(slug: string, origin: string) {
const sitemapDataResponse = await fetch(origin + "/sitemap-data.json");
const sitemapData = await sitemapDataResponse.json();
return sitemapData.find((entry: any) => entry.slug === slug);
}
export default async (request: Request, context: Context) => {
// NEW: get the slug from the request params (reference `config` below)
const { origin } = new URL(request.url);
const { slug } = context.params;
// NEW: use the slug to call the function that fetches the page data
const page = await getPageFromSitemap(slug, origin);
// NEW: return 404 if the page wasn't found in the sitemap
if (!page) return new Response("Not found", { status: 404 });
// render image and return ...
};
// NEW: add dynamic route parameter `slug`
export const config: Config = { path: "/preview-image/:slug" };

After this update, you’ll need to add a slug value to the URL when generating an image. Example: /preview-image/guide-to-netlify-dev.

And then you’ll see dynamic content!

An OG image with dynamic content

Notice that with the wrong slug (/preview-image/__WRONG__) you should get a 404 response.

#Build out the rest of the system

This is just enough to get you started. To prep this for production, you’ll have a few more tasks ahead of you.

#Add meta tags to layouts

You’ll want to make sure that you’re rendering the appropriate meta tag(s) to the <head> of the appropriate pages or layouts. This will vary from framework to framework, but should resolve to HTML like this:

<meta property="og:image" content="content="https://developers.netlify.com/preview-image/guides/..." />

#Other ideas for improvements

Here are some other considerations for improvement :

  • Character limitations for content so that it doesn’t flow outside the bounds of the image
  • Dynamic font-sizing based on the length of content
  • Optional overrides for title and description so that image content can differ from meta values
  • Different layouts/backgrounds for different types of pages

#Limitations and gotchas of this approach

It’s worth noting a few of the limitations and gotchas of this approach. I ran into several hurdles along the way that I hope you can avoid.

#satori is extremely limiting with styling

The engine used to produce these responses is awesome! But it’s also limited and very difficult to debug because it doesn’t tell you when you’ve done something wrong.

Through trial and error, here’s what I can tell you:

When in doubt, refer to the satori docs. I found that when I received an obscure error, it was usually the result of a satori limitation.

#Be mindful of external requests

I mentioned this in passing above, but it’s important to be mindful of external requests from these edge functions. You may need to come up with clever caching strategies to avoid limitations of external services when using this approach.

#…and have fun!

Most of all, I hope this is productive and fun for you to tinker with. Once I got it working, it was very cool to see these images come to life uniquely for every page on the site. It’s going to save us so much time as we consistently add new content.