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:
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.