Build Dynamic Open Graph (OG) Images
Create unique images for each blog post dynamically with Next.js
When creating content for the web, ensuring that it looks great when shared across social media platforms is essential. Meta tags play a crucial role in defining how your webpage appears when shared on platforms like Facebook, Twitter, or LinkedIn. These tags provide metadata about your webpage, including its title, description, and image, influencing how users perceive and engage with your content.
Open Graph (OG) images are a powerful part of this metadata. Specified using the og:image meta tag, they allow you to customize the visual content displayed alongside links to your site. By setting up dynamic OG images, you can make your blog posts more visually appealing, relevant, and engaging for your audience.
This guide will show you how to dynamically generate OG images for your blog using Next.js, enabling each post to have a unique, shareable image that stands out. The route below is a simplified version of the one this blog runs in production: the real route renders an OgCard component from my design system and takes its dimensions from the ogImageSize constant exported next to it, but I have inlined a small card here so the mechanics stay visible.
Step 1: Install the Dependency
First, ensure you have a Next.js project set up. The image generation itself is done by Satori under the hood, which you get through one of two packages:
Modern versions of Next.js (13.3 and later) also ship the same functionality built in as next/og, so if you are on a recent version you can skip the install entirely and import ImageResponse from next/og instead. This blog uses the standalone @vercel/og package; the API is identical.
ImageResponse lets you describe an image with JSX and a subset of CSS (flexbox via Tailwind-style tw props or inline styles), and it renders the result to a PNG on the server. That is all we need to build a card per post.
Step 2: Build an API Route for OG Images
Next, create a dynamic API route in your Next.js project to serve OG images. Since I am using the app router, the route lives in app/api/og/route.js.
I have a Contentlayer setup in my project, which gives me all the blog posts as a generated allPosts collection. The route takes a slug query parameter, finds the matching post, and falls back to the site title and description when there is no match. In practice only post pages ever call this route: the shared metadata generator (data/meta/generator/blog.js) gives the homepage and every other static page a pre-rendered /static/images/og-card.jpg unless a page passes its own image, so the fallback branch is a safety net rather than something the site relies on:
A few notes on the real version of this file, which you can read in full in the blog's repository:
- The card markup lives in the design system as an
OgCardcomponent (inline styles only, since Satori supports a subset of CSS), and the route passesogImageSize(1200×630, the recommended Open Graph size) as theImageResponseoptions instead of hardcoding the numbers. - The logo is an inline SVG passed to the card as a prop; I trimmed it above because its path data adds nothing to the explanation.
- Custom fonts have to be fetched as
ArrayBuffers and passed via thefontsoption ofImageResponse. I download four weights of Inter, cache the promise at module scope so each serverless instance fetches them at most once, and fall back to the default font if the download fails. - The route refuses to render drafts, so unpublished titles never leak through a guessable OG URL.
Step 3: Point Your Metadata at the Route
The route on its own does nothing until your pages reference it. In the app router, that means returning the OG image URL from generateMetadata (or the metadata export) of your post page. The minimal version looks like this:
Two details worth knowing: params is a Promise in Next.js 15, so it has to be awaited, and a relative image path like this only resolves to an absolute URL if your root metadata sets metadataBase to your site's origin.
This blog centralizes that logic in a shared metadata generator (data/meta/generator/post.js), which resolves the post for the requested slug and passes /api/og?slug=${post.slug} as the image to both the Open Graph and Twitter card metadata.
Every post now advertises a unique, automatically generated card with its own title, subtitle, and category, and I never have to open a design tool when I publish.
Validating the Result
Before trusting any of this, check what the crawlers actually see. Paste a post URL into the OpenGraph preview tool or the platform-specific debuggers (Facebook's Sharing Debugger and LinkedIn's Post Inspector). They fetch the page, resolve the og:image tag, and render the card exactly as it will appear in a feed, which catches wrong dimensions, missing tags, and slow image responses early.