Automate Open Graph Image Creation in Next.js

Automate Open Graph Image Creation in Next.js


10 min read

In this article, I'll share how you can dynamically create open graph images with Next.js.

What are Open Graph Images?

An open graph image is only one piece of a much larger set of open graph metadata that helps social media platforms build a display card for your content when it is shared. I previously published a full article on "How to Create Open Graph Social Media Cards" where you can learn much more about the open graph metadata your site needs. When you provide open graph image data in your metadata, the image becomes part of the generated social media card.

Why Generate Dynamic Open Graph Images?

By setting up your Next.js website to dynamically generate open graph images, you are automating part of your workflow. You may not want to do this for all pages on your website, but I believe you will find it very useful.

My Current Workflow

I'm currently using a mixed approach of manual and dynamic open graph images for my blog.

Here's what I do for each blog post:

  • Manually select an image on the web
  • Resize it to 1200px wide
  • Crop it to 1200x630
  • Save it with the same file name as my blog article
  • Load it up to the GitHub repository where I store my blog article MDX files

Now that I have typed the steps out, I can really see that there are several additional steps I have to take every time I create a blog post.

In contrast, the open graph images for my homepage and for my tag results pages are dynamically generated by Next.js. The images are created automatically with no extra steps!

What Do Dynamic Open Graph Images Look Like?

They can be as simple or complex as you want to make them.

Here's what my open graph image for the tag nextjs looks like:

Open Graph image for nextjs tag results on my blog

And here's what the open graph image for my homepage looks like: Open Graph image for my blog homepage

The images I'm dynamically creating with Next.js for my tag results pages are very simple.

The image I'm dynamically creating for my homepage has a more complex layout and fetches some vanity data from both GitHub and YouTube.

Let's look at how both of these are made with Next.js:

1. Specialized Route Handlers

Next.js supports opengraph-image and twitter-image files that can be placed alongside page files in any given route segment. Both of these files are specialized route handlers that will dynamically generate open graph images and twitter images respectively.

Note that twitter images are essentially the same as open graph images but specifically for Twitter (now X). Learn more about the metadata for open graph and twitter in my previous article "How to Create Open Graph Social Media Cards".

The Next.js docs provide some examples of how to generate images using code. I hope you benefit from seeing the code in my opengraph-image.tsx file as well.

My opengraph-image.tsx file has three functions in it:

  • generateStaticParams
  • generateImageMetadata
  • Image

Here is the full code for my opengraph-image.tsx:

// app/tags/[tag]/opengraph-image.tsx 

import { ImageResponse } from "next/og"
import { Inter } from "next/font/google"
import { getPostsMeta } from "@/lib/posts"

const websiteURL = process.env.NODE_ENV === 'production'
    ? ''
    : 'http://localhost:3000/'

const inter = Inter({
    weight: "400",
    subsets: ['latin']

export async function generateStaticParams() {
    const posts = await getPostsMeta()

    if (!posts) return []

    const tags = new Set( => post.tags).flat())

    return Array.from(tags).map((tag) => ({ tag }))

export async function generateImageMetadata({
    params: { tag }
}: {
    params: { tag: string }
}) {

    return [{
        id: tag,
        size: { width: 1200, height: 630 },
        alt: `Posts about ${tag}`,
        contentType: 'image/png',

export default async function Image({ id }: { id: string }) {

    return new ImageResponse(
            // ImageResponse JSX element
                    fontSize: 184,
                    background: 'black',
                    color: 'white',
                    width: '100%',
                    height: '100%',
                    display: 'flex',
                    justifyContent: 'center',
                    alignItems: 'center',
                    backgroundColor: 'black',
                    backgroundImage: `url(${websiteURL}images/og-card-bg-1.png)`,
        // ImageResponse options
            width: 1200,
            height: 630,

The generateStaticParams function is identical to the same function in my page.tsx file that generates dynamic pages for the tag results. This function returns an array of tag objects that is passed to the generateImageMetadata function.

The generateImageMetadata function can be used to return multiple images for one route segment. It does this by sending the objects in its returned array to the companion Image function. Note that generateImageMetadata returns an array of objects and each object must include an id value.

The Image function uses the ImageResponse constructor. This function receives the required id parameter from each object provided by the array from generateImageMetadata and creates an image for it. In the above example, you can see I assign my tag value to id in generateImageMetadata, and then I use the id value in the Image function.

2. Static vs Dynamic Output

By default, the dynamically generated images from the specialized route handlers are statically optimized. For something like tag results pages or even blog article pages, that should be good.

However, that will not be good if you need to fetch constantly updated data like the vanity metrics in the image for my homepage.

No worries!

You can change the static behavior with individual fetch options or route segment options.

Below is the code in my opengraph-image.tsx file for my homepage:

// app/opengraph-image.tsx 

import { ImageResponse } from 'next/og'
import { Inter } from 'next/font/google'
import { FaYoutube, FaGithub } from 'react-icons/fa6'

const websiteURL = process.env.NODE_ENV === 'production'
    ? ''
    : 'http://localhost:3000/'

export const runtime = 'edge'

const inter = Inter({
    weight: "400",
    subsets: ['latin']

// Image metadata
export const alt = 'Preview image for Dave Gray'

export const size = {
    width: 1200,
    height: 630,

export const contentType = 'image/png'

export default async function Image() {

    const githubData = fetch(``, {
        next: {
            revalidate: 0,
    }).then((res) =>

    const youtubeData = fetch(`${process.env.YOUTUBE_API_KEY}`, {
        headers: {
            Accept: 'application/json',
        next: {
            revalidate: 0,
    }).then((res) =>

    const [{
        followers: githubFollowers,
        bio: githubBio,
        name: githubName,
        { items: youtubeItems }
    ] = await Promise.all([githubData, youtubeData])

    const ytSubCount = youtubeItems?.length
        ? youtubeItems[0].statistics.subscriberCount
        : null

    return new ImageResponse(
            // ImageResponse JSX element goes here
        // ImageResponse options
            // For convenience, you can re-use the exported opengraph-image size config to also set the ImageResponse's width and height.

You can see the opengraph-image.tsx file above is similar but still very different from the previous example I gave.

This file is used to create one image. I am not using the generate functions that I used in the dynamic route segment for tags. Therefore, I define three exports for image metadata: alt, size, and contentType.

I am also using the edge runtime in this file. If you look at the runtime differences, you will see that edge does not support static rendering.

When you use the edge runtime, the revalidate route segment config option is not available. Because of this, I set revalidate to zero in each of the fetch requests instead.

The fetch requests use the Parallel Data Fetching pattern with Promise.all.

I did not share the HTML utilizing inlined CSS that makes up the JSX Element of the ImageResponse. It is truly an uglier version of the first example. Why create such ugliness with inline CSS? This question leads me to a discussion about the developer experience using the ImageResponse API. I will talk about that near the end of this article.

3. Output

The resulting output of this file is the open graph image I shared above that includes up-to-date vanity metrics. An updated image will be provided when my homepage is shared.

The only caveat is some social media sites prefer to cache open graph images. Adding any random parameter to the URL like ?dave or ?reset=1 should get the site to grab the latest image for your post.

When you create dynamic open graph images, Next.js also adds the related meta tags inside the <head> element of your website. Inspect your page with devtools, and you should find the following meta tags, but they may not be next to each other or in this order:

<meta property="og:image" content="">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Posts about nextjs">
<meta property="og:image:type" content="image/png">

4. Twitter Images (

Everything discussed above also applies to the twitter-image.tsx files you can create as well. The only difference between my opengraph and twitter files is the background image I applied to each and the chosen color of an icon. Share my homepage on and on LinkedIn to see the difference. You can do the same with my tag results pages like the one for articles tagged with nextjs.

Here's what my twitter image for the tag nextjs looks like:

Twitter image for the tag nextjs on my blog

And here's what the twitter image for my homepage looks like:

Twitter image for my blog homepage

The metadata Next.js creates for your twitter-image is different than the metadata for your opengraph-image. Here are the meta tags you should find inside your <head> element:

<meta name="twitter:image" content="">
<meta name="twitter:image:width" content="1200">
<meta name="twitter:image:height" content="630">
<meta name="twitter:image:alt" content="Posts about nextjs">
<meta name="twitter:image:type" content="image/png">

5. View Your Images

In dev mode, you can open up devtools, grab the URL for your open graph image from the meta tag, and paste it into another browser tab to display your image.

After you deploy your site, you can check your live URLs in several ways:

The last suggestion is the only way to currently test images on The site previously allowed you to test with a Twitter Card Validator. However, it no longer previews images.

Remember: Add a random parameter to the end of your URL to get the social media sites to refresh their cached image: ?random, ?abc, etc.

6. The Developer Experience

The specialized route handlers opengraph-image and twitter-image were introduced in Next.js version 13.3. If you already know how to create dynamic pages in Next.js, these files follow that pattern. This makes the learning curve intuitive.

One noticeable impact to the DX while working with these files - especially while tweaking the CSS - is that the image does not automatically update after you save your changes. You are probably used to your Next.js app doing that for you, but for these image route handlers, you must make a new request by hitting refresh in your browser in order to see your changes. This is simply how the web works though and should not reflect on Next.js in a negative way. You might want to use a browser extension like Auto Refresh Page that refreshes the page for you at a regular interval while you work on these.

The ImageResponse constructor is limited in what it supports. I mentioned ugly code earlier. The ugliness is really due to limited support for CSS properties and the need to inline all CSS with style. I would prefer to use Tailwind as I have on the rest of my site instead of applying inline styles. Not all CSS styles are supported either. I would like to see more support for CSS and possibly Tailwind inside of the ImageResponse constructor.

7. An Additional Option

You can learn a lot by looking at leerob's blog repo which is open to the public. As of this writing (December 2023), Lee's blog has not yet adopted the specialized opengraph-image and twitter-image files. Instead, he is using a standard route handler file to dynamically create his open graph images, and that is always an additional option.

Let's Connect!

Hi, I'm Dave. I work as a full-time developer, instructor and creator.

If you enjoyed this article, you might enjoy my other content, too.

My Stuff: Courses, Cheat Sheets, Roadmaps

My Blog:

YouTube: @davegrayteachescode

X: @yesdavidgray

GitHub: gitdagray

LinkedIn: /in/davidagray

Buy Me A Coffee: You will have my sincere gratitude

Thank you for joining me on this journey.