craigmadethis

Dynamic OG Image Generation In Next.js

Published: 31/10/2024, 07:45:00

Updated: 31/10/2024, 07:45:00

When I created this blog I wanted 0 friction to writing posts, but I still wanted them to use all the latest and greatest SEO magic because views = validation right?

Next.js OG image generation

This blog (and all my blogs) use Next.js. It has its quirks but I use it so often that changing to something like Astro would have likely led to another unfinished project. Using Next.js makes dynamically generating OG images super easy.

I love a good OG image, and I think they're vital when setting up a blog/website. If your site doesn't have an OG image, I'm probably not clicking on that link you just posted on Twitter. By dynamically generating it, I don't have to spent hours finding the "right" image. I can just git push my mdx, deploy the site and know that the reason no one looks at my blog is not the OG image.

The code

I've added a route at app/og/route.tsx, and in there I've defined the following (I'm using open-next, with SST so runtime args may be different for you):

app/og/route.tsx
import { ImageResponse } from "next/og";
import { NextRequest } from "next/server";
 
export const runtime = "nodejs";
 
export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;
  const postTitle = searchParams.get("title");
 
  const url = "https://craig.madethis.co.uk";
 
  // Fetching font
  const font = fetch(`${url}/Manrope-Regular.ttf`, {
    cache: "no-store",
  }).then((res) => res.arrayBuffer());
  const fontData = await font;
 
  return new ImageResponse(
    (
      <div
        style={{
          height: "100%",
          width: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "flex-start",
          justifyContent: "center",
          backgroundImage: `url(${url}/new_bg.png)`,
          backgroundSize: "100% 100%",
          backgroundRepeat: "no-repeat",
          position: "relative",
        }}
      >
        <div
          style={{
            marginLeft: 190,
            marginRight: 190,
            display: "flex",
            fontSize: 120,
            fontFamily: "Manrope",
            letterSpacing: "-0.05em",
            fontStyle: "normal",
            color: "white",
            lineHeight: "120px",
            whiteSpace: "pre-wrap",
          }}
        >
          {postTitle}
        </div>
        <div
          style={{
            marginLeft: 190,
            marginRight: 190,
            display: "flex",
            position: "absolute",
            bottom: 96,
            right: 0,
            fontSize: 64,
            fontFamily: "Manrope",
            letterSpacing: "-0.05em",
            fontStyle: "normal",
            color: "white",
            lineHeight: "64px",
            whiteSpace: "pre-wrap",
          }}
        >
          craigmadethis
        </div>
      </div>
    ),
    {
      width: 1920,
      height: 1080,
      headers: {
        "cache-control": "public, max-age=31536000, immutable",
      },
      fonts: [
        {
          name: "Manrope",
          data: fontData,
          style: "normal",
        },
      ],
    },
  );
}

Then to each blog I add the following:

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata | undefined> {
  const post = getBlogPosts().find((post) => post.slug === params.slug);
  if (!post) {
    return;
  }
 
  let {
    title,
    publishedAt: publishedTime,
    summary: description,
    image,
  } = post.metadata;
 
  let ogImage = image
    ? `https://craig.madethis.co.uk${image}`
    : `https://craig.madethis.co.uk/og?title=${title}`;
 
  return {
    title,
    description,
    openGraph: {
      title,
      description,
      type: "article",
      publishedTime,
      url: `https://craig.madethis.co.uk${slugWithYear(post)}`,
      images: [
        {
          url: ogImage,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title,
      description,
      images: [ogImage],
    },
  };
}

If there is an image for the post, that will render otherwise we dynamically generate the OG image using the article title.

ImageResponse

Now most of this just follows the ImageReponse docs that can be found in the Next.js docs on Dynamic Image Generation. ImageResponse accepts an element and an options object. The element is just a React component with some manual styling applied, with some caveats on what styles can be used outlined in the Next.js docs above (e.g. no css grid). The options allows us to define the size, caching and custom fonts.

Custom Fonts

This was a pain in the arse to set up. Figuring out where I needed to put my font file took longer than it probably should. There are no google fonts here, so we have to download the font we want to use and fetch it in the following block. The font should be placed in the public folder of your Next.js project:

  const url = 'https://craig.madethis.co.uk'
 
  const font = fetch(`${url}/Manrope-Regular.ttf`, {
    cache: "no-store",
  }).then((res) => res.arrayBuffer());
  const fontData = await font;

and then we append it to the options object:

return new ImageResponse(
  element, 
  {
    ...options, 
    fonts: [
        {
          name: "Manrope",
          data: fontData,
          style: "normal",
        },
    ]
  }
)

Custom background image

Similarly to the custom font, we need to place our image in the public directory, then access it using our site url. I'm pretty sure this has to be a jpg/png, none of that WebP stuff here.

We can then use this in our component, with the absolute site url:

<div
  {...props}
  style={{
  ...style, 
  backgroundImage: `url(${url}/bg_image.png)`
  }}
>
 
</div>
 

Resources

Other Posts