How we developed our method to generate dynamic OG Images using NextJS

Discover how we tackled dynamic OG image generation while developing APIInsights, utilizing NextJS for on-the-fly creation. Our journey uncovers the intricacies of background processes, metadata management, and the transformation from static to dynamic content delivery.

a month ago   •   7 min read

By Nikola Meheš
Table of contents

In the world of testing and generating results on the fly, we usually oversee small processes that happen in the background. For example, while you are reading this blog, besides the content, we need to generate a few other items like page title, keywords, description, author, and all the other metadata needed for search engine crawlers, browsers, and chats to digest information that is shared.

And it is all good when you have static information, like this blog. It is easy to task the designer to create a single image representing the content of the blog which is shown when you share this blog.

OG image of this article

But what when you have dynamic results?! When every page has its content created on the fly or somewhere on the API side. On pages like Speedtest by Ookla, WebPageTest, or in our case APIinsights, where content is dynamic, results are always different, and often immediately shared, it is much easier for the user that once the link is shared, partial information is already presented in the OG image.

SpeedTest result page OG image
SpeedTest example of OG image from the result page

At the moment of writing this blog, WebPageTest had an issue with generating such images using an external site resulting bad user experience when a link is shared.

2024011616 (1200×630)

And in our case here, I will explain how we created our method for creating dynamic images. Including examples of how to do it on the fly, and the option to save it on a public location, with pros and cons of each option.


Setup

When we were creating the design for APIinsights, we wanted to incorporate the Hero section from the report page into the shared OG image.

And, one particular problem occurred for us for some regular PHP side image generation - gradients and the possibility it will change.

While creating static images from a PHP .blade file and injecting data is fast and it is not impactful on performance, we couldn't reproduce satisfying visual results. Parallelly we decided to change the technologies we are using and chose to use NextJS. This opened up new possibilities for us on how to solve OG images.

In the events of the NextJS 13.4 release in May, one item caught our eye - the native ImageResponse constructor. And we started testing it out and we got satisfactory results pretty fast.

In the examples covered in this blog, we will use NextJS version 14.0.2 with React version 18.2.0. All running on NodeJS version 18.x in the background.
From other libraries that some of the examples will use, we will need puppeteer and puppeteer-core version 21.4.1, @sparticuz/chromium-min version 119.0.0, and @aws-sdk/client-s3 version 3.437.0.

One notable piece of information, based on the router type you are using in your NextJS project, ImageResponse is loaded from a different source.
In the /app router, you will import it with import { ImageResponse } from 'next/og'; in the case, you are using /pages router, you will need to use import { ImageResponse } from '@vercel/og';

Generating images from the virtual screenshot

In situations where you are not able to use NextJS's ResponseImage, we can write a simple Node function to make a screenshot and return it in the response.

The next example represents a way to do it with a puppeteer, which creates a virtual browser and opens up a page with our image made in React, creates a screenshot, and returns it as .png.

import { NextRequest, NextResponse } from "next/server";
import puppeteer from "puppeteer";
import { getClientVariable } from "~shared/envConfig";

export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
    const { id } = params;

    const url = `${getClientVariable("NEXT_PUBLIC_SITE_URL")}/reports/${id}/image`;

    if (!id) {
        return NextResponse
            .json(
                { error: "ID parameter is required" }, 
                { status: 400 }
            );
    }
    let browser;
    try {
        browser = await puppeteer.launch();
        const page = await browser.newPage();
        await page.goto(url);

        const screenshot = await page.screenshot({ type: "png", fullPage: true });
        const init = {
            headers: {
                "Content-Type": "image/png",
            },
        };
        const pages = await browser.pages();
        await Promise.all(pages.map((p) => p.close()));
        await browser.close();

        return new Response(screenshot, init);
    } catch {
        return NextResponse.json(
            { error: "Something went wrong" }, 
            { status: 500 }
        );
    } finally {
        if (browser) {
            await browser.close();
        }
    }
}

Pros

It works well, and it is simple to maintain. The backend does not care what the image is, and the design can be changed down the line. By doing so, we will make sure old reports will have updated OG images in the future.

Cons

The drawback of running this code is response time. While most of the clients will wait for the image, you could run into inconsistencies like us, where Slack and LinkedIn sometimes just ignore and do not show the image because the serverless function is not warm at the moment of the fetch.

A minor hustle would be the situation where you need an additional route just to show the image you will take.

Saving images in the public repository

To tackle response time, we can sideload a method that will save the image on the public repository, like AWS S3. On the frontend, we can inject OG image URL in a meta tag, if developers agree on a predefined URL and naming convention.

getReportOGImage function can be a function we defined earlier, which returns an image.

// helper functions
export const s3Client = new S3Client({
    region: getServerVariable("AWS_REGION"),
    credentials: {
        accessKeyId: getServerVariable("AWS_ACCESS_KEY_ID"),
        secretAccessKey: getServerVariable("AWS_SECRET_ACCESS_KEY"),
    },
});

export type UploadFileToS3Parameters = {
    fileBuffer: Buffer;
    fileName: string;
    fileType: string;
};

export const uploadFileToS3 = async (
    client: S3Client,
    { fileBuffer, fileName, fileType }: UploadFileToS3Parameters
) => {
    const putObjectCommandInput: PutObjectCommandInput = {
        Bucket: getServerVariable("AWS_BUCKET_NAME"),
        Key: fileName,
        Body: fileBuffer,
        ContentType: fileType,
    };

    const command = new PutObjectCommand(putObjectCommandInput);

    await client.send(command);
};
//the method itself
import { type NextRequest, NextResponse } from "next/server";
import { ImageResponse } from "next/og";

import { getReportOGImage } from "~entities/report/api/getReportImageOg";
import { uploadFileToS3, s3Client, type UploadFileToS3Parameters } from "~shared/api";

export async function POST(_request: NextRequest, { params }: { params: { id: string } }) {
    try {
        const { id } = params;

        const image = await getReportOGImage({ id });

        const imageBinaryBuffer = Buffer.from(await image.arrayBuffer());

        const s3parameters: UploadFileToS3Parameters = {
            fileName: `ogimage/${id}.png`,
            fileType: "image/png",
            fileBuffer: imageBinaryBuffer,
        };

        return uploadFileToS3(s3Client, s3parameters)
            .then(() => {
                return NextResponse.json("file upload successful", { status: 200 });
            })
            .catch(() => {
                return NextResponse.json("file upload failed", { status: 500 });
            });
    } catch {
        try {
            return new ImageResponse(
                (
                    <img
                        src={`https://apiinsights.io/assets/og/api-insights-analysis.jpg`}
                        alt="ogimage"
                    />
                ),
                {
                    width: 1200,
                    height: 630,
                }
            );
        } catch {
            return NextResponse.json({ message: "Method failed" }, { status: 500 });
        }
    }
}

Pros

Load time to get an image will be drastically improved, and if we take into account the combination of S3+CloudFront distribution, numbers will be even better. Once the image is generated you can be highly sure it will be matched with share and shown.

Cons

If the server returns 500, we will not have an image. But as well we are losing the possibility once the error is fixed to create an image, as this method is called once.

Another problem is the high number of dynamic events, we will fill out our data storage which can increase our bill significantly without any cleaning job.

The edge way

As mentioned earlier, we ended up creating OG with NextJs's ImageResponse.
However, we did not say why we use ImageResponse compared to a regular Response constructor. The key lies in the JSX support.

The facts are, that we wanted to avoid saving data on disk, so creating a purely dynamic option was the only option, and it should be as responsive as possible.

And for that, is a distinct line that needs proper attention.

export const runtime = "edge";

This setting defines which runtime we are using, more about the distinction between node, serverless, and edge can be found here. In simple words, we are putting our function like it is on CDN. There are pros and cons to these functions but we will leave this for another time.

import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { ImageResponse } from "next/og";
import { getReport } from "~entities/report";
import { OgImage } from "~components";

export const runtime = "edge";

export async function GET(_request: NextRequest, { params }: { params: { id: string } }) {
    try {
        const { id } = params;

        const response = await getReport({ id });

        if (response.report === undefined) {
            return new ImageResponse(
                (
                    <img
                        src={`https://apiinsights.io/assets/og/api-insights-analysis.jpg`}
                        alt="ogimage"
                    />
                ),
                {
                    width: 1200,
                    height: 630,
                }
            );
        }

        /* ... */

        const data = {
            /* ... */
        };

        return new ImageResponse(<OgImage data={data} />, {
            width: 1200,
            height: 630,
        });
    } catch {
        try {
            return new ImageResponse(
                (
                    <img
                        src={`https://apiinsights.io/assets/og/api-insights-analysis.jpg`}
                        alt="ogimage"
                    />
                ),
                {
                    width: 1200,
                    height: 630,
                }
            );
        } catch {
            return NextResponse.json({ message: "Failed to generate image!" }, { status: 500 });
        }
    }
}

Pros

It is blazingly fast once deployed on Vercel. And performance is great in all parts of the world. As well, this implementation is extremely scalable.

Cons

The speed of the Edge Runtime is derived from its efficient resource utilization, yet this can pose limitations in various situations. To illustrate, the Edge Runtime on Vercel imposes a code execution limit ranging from 1 MB to 4 MB.

End thoughts

With the presented implementations you can see our journey and how we got to the final solution. But to determine whether is this the best approach for you, it will depend on your business case and how scalable the solution needs to be. In our case, to avoid saving data on disk, we opted for a purely dynamic solution, ensuring both the necessity and responsiveness of our approach.

But it is great to uncover modern technologies and advancements in frameworks that can ease some common, often underrated problems that impact user experience.

To truly appreciate the effectiveness of these implementations, you are encouraged to try API insights and witness the Dynamic OG image in action.

Spread the word

Keep reading