Chris.luChris.lu header image, displaying an 80s style landscape and sunset

Open Graph Image(s)

In the previous part, we saw how to use the Open Graph protocol in our metadata object, but an important part is still missing, which is why in this part, we are going to focus on creating open graph images for our Next.js 15 project

Next.js comes with a great feature called next/og which under the hood uses @vercel/og

To get a better idea of what it can do, I recommend visiting the "OG image" playground, which has some good examples of what can be achieved

Next OG images can do more than just use a static image as opengraph image. You can import custom fonts and images and even use CSS and HTML to build your opengraph scene, then @vercel/og will convert your code into an image using Satori (and Satori is based on resvg-js)

Compared to the Next.js 14 this version of the opengraph images tutorial has some changes due to the "dynamic APIs" breaking change introduced in Next.js 15, which means we now await the params as they are now an asynchronous function

Static Open Graph Image (and static alt)

First, we will create a simple static opengraph image

We need 2 static assets to compose our og image, a font and a background image, as font we will use the same font we used in our layout.tsx for the next/font examples, which is Kablammo from google fonts

To get the font visit the google fonts Kablammo page, and then click on the Get font button. On the next page, click on the Download all button (if the button is not there, first click on the shopping bag icon in the top right of the screen). Next, go into the public folder of your project and create a new /fonts folder. Now go into your download folder and unzip the font package (which you just downloaded from google fonts). Open the /static folder, and then copy the Kablammo-Regular.ttf file into the /public/fonts folder.

Our font is pretty big, it is >500kb, which is quite heavy but still ok. Usually, fonts are smaller. Be careful, when choosing a font as some might be bigger than that. If you choose a font over 2MB, you will get the following error:

Error: Failed to set Next.js data cache, items over 2MB can not be cached

As image, we will NOT reuse the one we downloaded earlier in the "How to NOT add images to an MDX document" part, because it is very heavy (>2.25MB) (and this time we won't have next/image optimize the image), a font that is >2.25MB would be too heavy (see cache error above).

Instead, we can go back to the "Stranger Things 2 Sign in City at Night" download page, click again on Free download, and this time choose the Medium 1280x800 version. The recommended size for opengraph images is 1200x630, so our 1280x800 background image will only be a little bit too big, which is good enough. At a file size of 178kb, it is also much smaller than the other version.

Or, if you know how to use an image editor like Gimp, you could use the original image and resize it to be exactly 1280x800 pixels.

Finally, move the image to the /public/images folder and rename it to og_background.jpg

Tip

If you only need one background image, you could resize and optimize it manually by using an online tool like ezgif.com

If you need more than one image, then you might consider using sharp; which is the package that Next.js when it optimizes and resizes images locally; you could write a little script that automates the optimization of all the images you plan on using (as backgrounds or inside of your opengraph images)

Now that we have our assets go into the /app/blog folder and create a new opengraph-image.tsx file:

/app/blog/opengraph-image.tsx
import { ImageResponse } from 'next/og'
 
const title = 'Static Blog Title'
 
// Route segment config
export const runtime = 'edge'
 
export const size = {
    width: 1200,
    height: 630,
}
 
export const contentType = 'image/png'
export const alt = `Example.com ${title} banner`
 
export default async function OGImage(/*props: IImageProps*/) {
 
    const kablammoFont = fetch(
        new URL('../../public/fonts/Kablammo-Regular.ttf', import.meta.url)
    ).then((res) => res.arrayBuffer())
 
    const backgroundImage = await fetch(
        new URL('../../public/images/og_background.jpg', import.meta.url)
    ).then((res) => res.arrayBuffer())
 
    return new ImageResponse(
        // ImageResponse JSX element
        (
            <div
                style={{
                    width: '100%',
                    height: '100%',
                    display: 'flex',
                    alignItems: 'center',
                    justifyContent: 'center',
                }}
            >
                {
                    // eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element 
                    <img
                        // @ts-expect-error: this is fine 🔥
                        src={backgroundImage}
                        style={{
                            objectFit: 'cover',
                            objectPosition: 'center',
                        }}
                    />
                }
                <div
                    style={{
                        position: 'absolute',
                        width: '100%',
                        height: '200px',
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        margin: 0,
                        fontFamily: 'Kablammo',
                        fontWeight: 400,
                        fontStyle: 'normal',
                        fontSize: '80',
                        background: 'rgb(0, 0, 0, 0.5)',
                        color: 'rgb(255, 255, 255)',
                    }}
                >
                    <span>{title}</span>
                </div>
            </div >
        ),
        // ImageResponse options
        {
            // For convenience, we can reuse the exported opengraph-image
            // size config to also set the ImageResponse's width and height.
            ...size,
            fonts: [
                {
                    name: 'Kablammo',
                    data: await kablammoFont,
                    style: 'normal',
                    weight: 400,
                },
            ],
        }
    )
}

Line 1: we import the Next.js opengraph image tool

Line 3: we set a static title for all our opengraph images, this variable will be used for the alt text of our opengraph image

Lines 5 to 14: we export some variables that will tell Next.js metadata what values to use for the opengraph tags (the og:image tags which are related to the image)

Lines 18 to 24: we use fetch to get both the font file and the background image

Lines 26 to 85: we use basic HTML and CSS to create the content of our opengraph image; we created a div that covers the whole width and height and used the background image to fill it; then we put a smaller div inside, using position: absolute to position it over the background; that div will also display the title; we finally made the background of the title div a bit darker using a black color with the opacity set 0.5 to make it semi transparent

Note

Btw did you notice how line 45 I turned a meme into a typescript comment? 😉

Make sure the dev server is running and then visit the http://localhost:3000/blog blog page. If you then look at the tags inside of the <head> element, you will notice that Next.js has added 5 new opengraph tags for our image:

<meta property="og:image:alt" content="Example.com Blog banner">
<meta property="og:image:type" content="image/png">
<meta property="og:image" content="http://localhost:3000/blog/opengraph-image?CACHE_BUSTING_HASH">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">

If you now open the http://localhost:3000/blog/opengraph-image image URL you can see the output of your new opengraph image generator

potential problems and their solutions (optional)

Turbopack absolute path problem (on windows)

If you are using Turbopack and use an absolute path (without protocol) in your code (on windows), then you will get a compiler error:

compiler error:
 
Error evaluating Node.js code
Error: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'

This means that when using Turbopack and fetching files like we did in the code above, you need to make sure to use a relative path (like we did) or an absolute path but then you need to prefix it using the file:// protocol

The "metadataBase" problem, 404 response

There is one problem related to the metadataBase, where the opengraph image will just return a 404

You might see an error like this in the terminal:

 metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "http://localhost:3000". See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase

If that is the case, the workaround I found to work well is to set the metadataBase in the root layout to a default value like this:

/app/layout.tsx
import './global.css'
import { Metadata } from 'next'
import HeaderNavigation from '@/components/header/Navigation'
import { Kablammo } from 'next/font/google'
import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next'
 
export const metadata: Metadata = {
    metadataBase: process.env.VERCEL_URL
        ? new URL(`https://${process.env.VERCEL_URL}`)
        : new URL(`http://localhost:${process.env.PORT ?? '3000'}`),
    title: {
        template: '%s | example.com',
        default: 'Home | example.com',
    },
    description: 'My description',
    openGraph: {
        url: 'https://example.com/',
        siteName: 'My website name',
        locale: 'en_US',
        type: 'website',
    },
}

Lines 9 to 11: we set the default metadataBase to use localhost in development and VERCEL_URL (otherwise if you don't use Vercel to deploy, either use an environment variable that works for your deployment tool or just set a static value that contains your domain)

Dynamic Open Graph Image

Static images are a great start, but what about more advanced scenarios where you might need a dynamic opengraph image generator

You could, of course, duplicate the opengraph script we just did and add one in each of your pages folders, but it is also possible to create a dynamic opengraph image using a tsx file

Warning

If like me, you are on the Vercel free plan (Hobby plan), be careful NOT to use images that are very heavy or use a lot of images (or other assets like fonts) in your og image script because Vercel functions have a limit of 1MB; so if your PNG background image is 1.5 MB you will get this error during the build process:

"Error: The Edge Function "web_development/opengraph-image" size is 1.68 MB, and your plan size limit is 1 MB. Learn More: https://vercel.link/edge-function-size"

In case you wonder Hobby: 1 MB, Pro: 2 MB, Enterprise: 4 MB

Dynamic OG Image for dynamic route segment

If you use dynamic route for your page then you will also want to have dynamic opengraph images

Let's create a new folder /posts in our /app/blog folder, and then inside of it another /[title] folder that will act as a dynamic segment for our route

Finally, inside our new /app/blog/posts/[title] folder, create an opengraph-image.tsx file:

/app/blog/posts/[title]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
 
// Route segment config
export const runtime = 'edge'
 
const size = {
    width: 1200,
    height: 630,
}
 
interface OpenGraphImageProps {
    params: {
        title: string
    }
}
 
export function generateImageMetadata({ params }: OpenGraphImageProps) {
 
    return [
        {
            id: 'blog_opengraph',
            alt: `${params.title} | example.com`,
            size: size,
            contentType: 'image/png',
        },
    ]
}
 
export default async function OGImage(props: OpenGraphImageProps) {
 
    const { title } = props.params
 
    const kablammoFont = fetch(
        new URL('../../../../public/fonts/Kablammo-Regular.ttf', import.meta.url)
    ).then((res) => res.arrayBuffer())
 
    const backgroundImage = await fetch(
        new URL('../../../../public/images/og_background.jpg', import.meta.url)
    ).then((res) => res.arrayBuffer())
 
    return new ImageResponse(
        // ImageResponse JSX element
        (
            <div
                style={{
                    width: '100%',
                    height: '100%',
                    display: 'flex',
                    alignItems: 'center',
                    justifyContent: 'center',
                }}
            >
                {
                    // eslint-disable-next-line jsx-a11y/alt-text, @next/next/no-img-element 
                    <img
                        // @ts-expect-error: this is fine 🔥
                        src={backgroundImage}
                        style={{
                            objectFit: 'cover',
                            objectPosition: 'center',
                        }}
                    />
                }
                <div
                    style={{
                        position: 'absolute',
                        width: '100%',
                        height: '200px',
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        margin: 0,
                        fontFamily: 'Kablammo',
                        fontWeight: 400,
                        fontStyle: 'normal',
                        fontSize: '80',
                        background: 'rgb(0, 0, 0, 0.5)',
                        color: 'rgb(255, 255, 255)',
                    }}
                >
                    <span>{title}</span>
                </div>
            </div >
        ),
        // ImageResponse options
        {
            // For convenience, we can reuse the exported opengraph-image
            // size config to also set the ImageResponse's width and height.
            ...size,
            fonts: [
                {
                    name: 'Kablammo',
                    data: await kablammoFont,
                    style: 'normal',
                    weight: 400,
                },
            ],
        }
    )
}

The code is very similar to the code we used for the "static" version. One difference is that we removed the static title variable, and we also don't export the sizes variable anymore (those static exports get replaced by the usage of the generateImageMetadata function lines 17 to 27)

Lines 11 to 15: we added an interface for the Image function props

Lines 17 to 27: we use the generateImageMetadata function to create dynamic opengraph image metadata. The alt value will be dynamic because we use the title param (that contains the title value from our dynamic route segment)

Line 31: we also get the title param inside of the Image function, as this is the value we will use now inside of our span line 83 as a replacement for the previously static title

The rest of the code, including the styling, has not changed (compared to the previous static example)

The last step is to create the posts page (to simplify the code of this example, we will only add the dynamic metadata part and not fetch any blog post content from the filesystem or database, but you could do just that using the Next.js generateStaticParams function):

/app/blog/posts/[title]/page.tsx
import { sharedMetadata } from '@/shared/metadata'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
 
interface BlogPostsProps {
    params: Promise<{ title: string }>
}
 
const titlesAllowList = ['foo'] as string[]
 
export async function generateMetadata({ params }: BlogPostsProps): Promise<Metadata> {
 
    let title = '' as string
 
    const pageParams = await params
 
    if (titlesAllowList.includes(pageParams.title)) {
        title = pageParams.title
    }
 
    return {
        title: title,
        openGraph: {
            ...sharedMetadata.openGraph,
            url: `https://example.com/blog/posts/${title}`,
        },
    }
 
}
 
export default async function Blog({
    params,
}: BlogPostsProps) {
    // as we are getting user data we need 
    // to sanitize it or use an allow list
    let title = '' as string
 
    const pageParams = await params
 
    if (titlesAllowList.includes(pageParams.title)) {
        title = pageParams.title
    } else {
        notFound()
    }
 
    return (
        <>
            I&apos;m the &quot;{title}&quot; blog post page
        </>
    )
}

Line 2: we import the Metadata type to add type information to the return value of our customized generateMetadata function

Line 3: we import the notFound function from the next/navigation package; what the notFound function does is well explained in the typescript tooltip:

In a Server Component, this will insert a meta tag and set the status code to 404. In a Route Handler or Server Action, it will serve a 404 to the caller.

Line 9: as we will deal with params, which are content the user can modify, we must make sure those values get sanitized before we use them as a file path or in a database query; another solution would be to use an allow list to exclude any dynamic value that does not match the values we in our allow list, which is what we will do in this example

Lines 11 to 29: we use the Next.js generateMetadata function to get the title from the params; we then check if it is in our allow list and if it is valid we create the metadata

Lines 40 to 44: we do the same "is in allow list" check again; if valid we use the title variable in our JSX; if invalid we use the notFound function from next/navigation to create a 404 response

Make sure the dev server is running, and then you can visit the http://localhost:3000/blog/posts/foo page and look at the dynamic opengraph image meta tags inside of <head> element, and to see the image itself visit http://localhost:3000/blog/posts/foo/opengraph-image/blog_opengraph

Now that you know how to create dynamic versions, you could even go further, if for example you use similar code to the what we used to make our sitemap dynamic. You could read the content of MDX files, extract the frontmatter and then use the frontmatter values for your dynamic opengraph images

Congratulations 🎉 you now know how to create static and dynamic opengraph images

If you liked this post, please consider buying me a coffee ☕ or sponsor ❤️ me on GitHub, as it will help me create more content and keep it free for everyone