Dynamically Generating Open Graph Images Using Next.js API Routes

May 17, 2025

We write a lot of blog posts at Polar Signals, to share more about our product work and engineering processes and learnings. As a result of that, we always tried to add an Open Graph image to our blog posts because studies have shown that Open Graph images can increase the click-through rate of your content by 42%.

So on a Friday, I decided to build a dynamic Open Graph image generator using Next.js API routes and the @vercel/og library. The open graph image generator is a simple API route that takes in a title, tags, and authors and returns an image. This is then automatically used in the post's meta tags.

In this post, I'll share how I built the open graph image generator and how you can use it to generate Open Graph images for your own content.

The first thing I did was to create a design in Figma for what the open graph image would look like. I shared several versions with the team and we settled on this one:

Sample image for the open graph image

Creating the API Route

Now for this, I'm assuming you're using Next.js and have a basic understanding of how to create API routes.

First, create a file at pages/api/og.tsx with the following structure:

import { ImageResponse } from "@vercel/og";
import { type NextRequest } from "next/server";

export const config = {
  runtime: "edge",
};

Loading Custom Fonts

For branded, custom typography, we need to load and use custom fonts. I settled on the Inter font family for this project.

const InterMediumFont = fetch(
  new URL("../../../public/fonts/Inter-Medium.woff", import.meta.url)
).then(async (res) => await res.arrayBuffer());

const InterBoldFont = fetch(
  new URL("../../../public/fonts/Inter-Bold.woff", import.meta.url)
).then(async (res) => await res.arrayBuffer());

3. Add Helper Functions

We may need helper functions for processing data. For example, limiting the number of tags displayed. I've added a helper function to limit the number of tags to 5.

const reduceTagsLength = (tags: string[] | undefined) => {
  if (!tags) return;

  if (tags.length > 5) {
    return tags.slice(0, 5);
  }

  return tags;
};

Create the Handler Function

The main handler function processes the request, extracts parameters, and generates the image:

export default async function handler(req: NextRequest) {
  const fontMediumData = await InterMediumFont;
  const fontBoldData = await InterBoldFont;

  try {
    const { searchParams } = new URL(req.url);

    // Extract parameters from URL
    const title = searchParams.has("title")
      ? searchParams.get("title")?.slice(0, 100)
      : "My default title";

    const tags = searchParams.has("tags")
      ? searchParams.get("tags")?.slice(0, 100)
      : undefined;

    const authors = searchParams.has("authors")
      ? searchParams.get("authors")?.slice(0, 100)
      : "Default Authors";

    // Process data
    const tagsSplit = reduceTagsLength(tags?.split(","));
    const capitalizedTags = tagsSplit
      ?.map((tag) => tag.charAt(0).toUpperCase() + tag.slice(1))
      .join(", ");
    const authorsAsArray = authors?.split(",").map((author) => author.trim());

    // Return the image response
    return new ImageResponse(
      (
        // JSX template
        <div>...</div>
      ),
      {
        width: 1200,
        height: 630,
        fonts: [
          {
            name: "InterMedium",
            data: fontMediumData,
            style: "normal",
          },
          {
            name: "InterBold",
            data: fontBoldData,
            style: "normal",
          },
        ],
      }
    );
  } catch (e: any) {
    return new Response("Failed to generate the image", {
      status: 500,
    });
  }
}

Designing the OG Image

The JSX template is where the design above is converted into a React component. The below is a rough representation of the JSX template. This is actally where you get to inject all the data from the request into the image.

<div
  style={{
    backgroundColor: "#F8F8F8",
    height: "100%",
    width: "100%",
    display: "flex",
    flexDirection: "column",
    position: "relative",
    fontFamily: "InterMedium",
  }}
>
  {/* Logo */}
  <div style={{ position: "absolute", top: "35px", left: "50px" }}>
    <svg>...</svg>
  </div>

  {/* Title */}
  <div style={{ paddingLeft: 50, paddingRight: 50 }}>
    <div
      style={{
        fontSize: 48,
        fontWeight: 500,
        color: "#374151",
        fontFamily: "InterBold",
      }}
    >
      {title}
    </div>
  </div>

  {/* Tags and Authors */}
  <div style={{ position: "absolute", bottom: "50px", left: "50px" }}>
    {tags && <div>...</div>}
    {authorsAsArray && <p>...</p>}
  </div>

  {/* Gradient Bar */}
  <div
    style={{
      position: "absolute",
      bottom: 0,
      height: 30,
      width: "100%",
      backgroundImage: "linear-gradient(...)",
    }}
  />
</div>

The example above is a bit simplified, but you get the idea.

The important thing to note is that the image is generated dynamically based on the title, tags, and authors. This means that you can change the image without having to manually update it.

To round this up, here's the entire API route:

import { ImageResponse } from "@vercel/og";
import { type NextRequest } from "next/server";

export const config = {
  runtime: "edge",
};

const InterMediumFont = fetch(
  new URL("../../../public/fonts/Inter-Medium.woff", import.meta.url)
).then(async (res) => await res.arrayBuffer());

const InterBoldFont = fetch(
  new URL("../../../public/fonts/Inter-Bold.woff", import.meta.url)
).then(async (res) => await res.arrayBuffer());

const reduceTagsLength = (tags: string[] | undefined) => {
  if (!tags) return;

  if (tags.length > 5) {
    return tags.slice(0, 5);
  }

  return tags;
};

export default async function handler(req: NextRequest) {
  const fontMediumData = await InterMediumFont;
  const fontBoldData = await InterBoldFont;

  try {
    const { searchParams } = new URL(req.url);

    // Extract parameters from URL
    const title = searchParams.has("title")
      ? searchParams.get("title")?.slice(0, 100)
      : "My default title";

    const tags = searchParams.has("tags")
      ? searchParams.get("tags")?.slice(0, 100)
      : undefined;

    const authors = searchParams.has("authors")
      ? searchParams.get("authors")?.slice(0, 100)
      : "Default Authors";

    // Process data
    const tagsSplit = reduceTagsLength(tags?.split(","));
    const capitalizedTags = tagsSplit
      ?.map((tag) => tag.charAt(0).toUpperCase() + tag.slice(1))
      .join(", ");
    const authorsAsArray = authors?.split(",").map((author) => author.trim());

    // Return the image response
    return new ImageResponse(
      (
        // JSX template
        <div>...</div>
      ),
      {
        width: 1200,
        height: 630,
        fonts: [
          {
            name: "InterMedium",
            data: fontMediumData,
            style: "normal",
          },
          {
            name: "InterBold",
            data: fontBoldData,
            style: "normal",
          },
        ],
      }
    );
  } catch (e: any) {
    return new Response("Failed to generate the image", {
      status: 500,
    });
  }
}

Using the OG Image Generator

Now you can generate dynamic OG images by calling your API with parameters:

https://yourdomain.com/api/og?title=My%20Awesome%20Article&tags=nextjs,react,webdev&authors=Jane%20Doe,John%20Smith

Incorporate this URL in your blog post's component page's SEO tags. This is where the Open Graph image is actually used.

<NextSeo
  title={meta.title}
  description={meta.description}
  openGraph={{
    title: meta.title,
    description: meta.description,
    type: "article",
    images: [
      {
        url: `https://yourdomain.com/api/og?title=${encodeURI(
          meta.title as string
        )}&authors=${encodeURI(
          (meta.authors as string[]).join()
        )}&tags=${encodeURI((meta.tags as string[]).join())}`,
      },
    ],
  }}
/>

That's it! You can now generate dynamic Open Graph images for your content. Feel free to reach out to me if you have any questions.