Dynamic OG Images
Create an opengraph-image.tsx file in any route segment. Next.js renders it with ImageResponse on the edge — you can use JSX, custom fonts, and even fetch data to make each image unique.
Open Graph & Twitter Card Images
When someone shares your blog post on Twitter, LinkedIn, or Slack, the first thing people see is the preview image. A bland, generic image gets ignored. A sharp, on-brand image with the post title and author gets clicked. The difference in click-through rate can be dramatic — and it costs you nothing extra if you generate these images automatically.
Next.js has a built-in system for generating dynamic Open Graph images using JSX and CSS — no Puppeteer, no headless Chrome, no external service required. The images are generated at the edge, cached automatically, and each one is unique to its page. This lesson covers everything from basic static OG images to fully dynamic, data-driven image generation.
1. The Two Approaches to OG Images
Next.js gives you two ways to handle OG images:
| Approach | How it works | Best for |
|---|---|---|
| Static file | Place opengraph-image.png in a route folder |
Pages that always show the same image |
| Dynamic generation | Create opengraph-image.tsx that returns an ImageResponse |
Pages where the image should reflect the page content |
For most real projects you'll use both — a static default OG image at the root level and dynamic images for blog posts, product pages, and user profiles.
2. Static OG Images — The Simple Case
Place an image file named opengraph-image (with any image extension)
in any route folder. Next.js automatically adds the correct
<meta property="og:image"> tag for that route:
app/
├── opengraph-image.png ← Default OG image for the whole site
├── page.tsx
└── blog/
├── opengraph-image.png ← OG image specifically for /blog
└── page.tsx
Supported filenames and their purposes:
| File | Meta tag generated |
|---|---|
opengraph-image.png |
og:image |
twitter-image.png |
twitter:image |
opengraph-image.jpg |
og:image |
opengraph-image.gif |
og:image |
Design your static OG images at exactly 1200×630 pixels. Export them as PNG or JPG (PNG for images with text, JPG for photographs). Keep file size under 1MB — most platforms have size limits for OG image previews.
3. Dynamic OG Images with ImageResponse
Create a file named opengraph-image.tsx (note the .tsx
extension — this is a JavaScript file, not an image file). Export a default
function that returns an ImageResponse:
// app/opengraph-image.tsx — default site-wide OG image
import { ImageResponse } from "next/og";
// Tell Next.js to generate this as a static image at build time
export const runtime = "edge";
export const alt = "Acme Corp";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function OgImage() {
return new ImageResponse(
(
<div
style={{
background: "linear-gradient(135deg, #1e3a8a 0%, #2563eb 100%)",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
fontFamily: "sans-serif",
}}
>
<div
style={{
fontSize: 72,
fontWeight: 800,
color: "white",
letterSpacing: "-2px",
}}
>
Acme Corp
</div>
<div
style={{
fontSize: 28,
color: "rgba(255, 255, 255, 0.8)",
marginTop: 16,
}}
>
Build better software, faster
</div>
</div>
),
{ ...size }
);
}
Next.js renders this JSX to a PNG image using the @vercel/og package
(bundled with Next.js). The JSX is limited to a subset of CSS — we'll cover the
constraints in detail below.
4. Dynamic OG Images for Blog Posts
The real power is per-page dynamic images. Add an opengraph-image.tsx
inside a dynamic route folder — it receives the same params as the
page and can fetch data to generate a unique image for each post:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { db } from "@/lib/db";
export const runtime = "edge";
export const alt = "Blog post";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OgImage({
params,
}: {
params: { slug: string };
}) {
// Fetch the post data
const post = await db.post.findUnique({
where: { slug: params.slug },
select: {
title: true,
excerpt: true,
author: { select: { name: true, avatar: true } },
category: { select: { name: true } },
},
});
if (!post) {
// Return a fallback image if the post doesn't exist
return new ImageResponse(
<div style={{ background: "#1e3a8a", width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ color: "white", fontSize: 48 }}>Post Not Found</div>
</div>,
{ ...size }
);
}
return new ImageResponse(
(
<div
style={{
background: "white",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
padding: "60px 80px",
fontFamily: "sans-serif",
}}
>
{/* Top accent bar */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 8,
background: "linear-gradient(90deg, #2563eb, #7c3aed)",
}}
/>
{/* Category tag */}
<div
style={{
display: "flex",
alignItems: "center",
marginTop: 20,
}}
>
<div
style={{
background: "#dbeafe",
color: "#1d4ed8",
fontSize: 18,
fontWeight: 600,
padding: "6px 16px",
borderRadius: 999,
}}
>
{post.category?.name ?? "Article"}
</div>
</div>
{/* Post title */}
<div
style={{
fontSize: post.title.length > 60 ? 48 : 60,
fontWeight: 800,
color: "#111827",
lineHeight: 1.15,
marginTop: 32,
flex: 1,
}}
>
{post.title}
</div>
{/* Author row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
marginTop: 40,
}}
>
{/* Author avatar */}
{post.author.avatar && (
<img
src={post.author.avatar}
width={52}
height={52}
style={{ borderRadius: "50%", border: "3px solid #e5e7eb" }}
/>
)}
{/* Author info */}
<div style={{ display: "flex", flexDirection: "column" }}>
<div style={{ fontSize: 20, fontWeight: 600, color: "#374151" }}>
{post.author.name}
</div>
<div style={{ fontSize: 16, color: "#9ca3af" }}>
acmecorp.com
</div>
</div>
{/* Site logo — right side */}
<div
style={{
marginLeft: "auto",
fontSize: 28,
fontWeight: 800,
color: "#2563eb",
letterSpacing: "-1px",
}}
>
Acme Corp
</div>
</div>
</div>
),
{ ...size }
);
}
Every blog post now gets its own unique OG image showing the post title, category, author name, and avatar — automatically, without any manual image creation.
5. Loading Custom Fonts in OG Images
The default ImageResponse uses system fonts. For brand-consistent
OG images, load your custom font — Google Fonts work well since they're publicly
accessible:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
// Load the font once outside the function — cached across requests
async function loadFont(text: string) {
const url = "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff";
const response = await fetch(url);
return response.arrayBuffer();
}
export default async function OgImage({
params,
}: {
params: { slug: string };
}) {
const [post, interFont] = await Promise.all([
getPostBySlug(params.slug),
loadFont(""),
]);
return new ImageResponse(
(
<div
style={{
fontFamily: "'Inter'",
// ... rest of your JSX
}}
>
<div style={{ fontWeight: 800 }}>{post?.title}</div>
</div>
),
{
...size,
fonts: [
{
name: "Inter",
data: interFont,
style: "normal",
weight: 800,
},
],
}
);
}
Alternatively, use a local font file from your project. Place it in the
public/fonts/ folder and fetch it with a relative URL:
// Fetch a local font file
const fontData = await fetch(
new URL("../../../public/fonts/Inter-Bold.ttf", import.meta.url)
).then((res) => res.arrayBuffer());
6. CSS Constraints in ImageResponse
ImageResponse doesn't support the full CSS specification — it uses
a subset powered by the satori library. Knowing what's supported
prevents hours of debugging:
Supported CSS
- Flexbox layout (
display: flex) — this is the primary layout system - Absolute and relative positioning
- Background colours, gradients, and images
- Border, border-radius, box-shadow
- Font properties (size, weight, family, color, line-height, letter-spacing)
- Width, height, padding, margin, gap
- Opacity
- Overflow: hidden
- Object-fit for images
NOT Supported
display: grid— use nested flexbox instead- CSS animations and transitions
calc()expressions- CSS variables (
var(--color)) - Pseudo-elements (
::before,::after) text-overflow: ellipsis— truncate text in JavaScript instead- Most Tailwind classes — use inline
styleobjects only
The golden rule: use display: flex for everything
and inline style objects instead of className. Tailwind classes are not supported
inside ImageResponse.
7. Dynamic OG Images for Product Pages
Here's a product page OG image that pulls in the product image, name, price, and rating — making every product's social share look like a proper ad:
// app/product/[id]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { db } from "@/lib/db";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OgImage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({
where: { id: params.id },
select: { name: true, price: true, rating: true, image: true, category: true },
});
if (!product) return new Response("Not found", { status: 404 });
const stars = "★".repeat(Math.round(product.rating)) + "☆".repeat(5 - Math.round(product.rating));
return new ImageResponse(
(
<div
style={{
background: "#f9fafb",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
padding: "60px",
gap: "60px",
fontFamily: "sans-serif",
}}
>
{/* Product image */}
<div
style={{
width: 460,
height: 460,
borderRadius: 24,
overflow: "hidden",
background: "white",
boxShadow: "0 20px 60px rgba(0,0,0,0.12)",
flexShrink: 0,
}}
>
<img
src={product.image}
width={460}
height={460}
style={{ objectFit: "cover" }}
/>
</div>
{/* Product details */}
<div
style={{
display: "flex",
flexDirection: "column",
flex: 1,
gap: 20,
}}
>
<div style={{ fontSize: 20, color: "#6b7280", fontWeight: 500 }}>
{product.category}
</div>
<div
style={{
fontSize: product.name.length > 40 ? 44 : 56,
fontWeight: 800,
color: "#111827",
lineHeight: 1.1,
}}
>
{product.name}
</div>
<div style={{ fontSize: 28, color: "#f59e0b", letterSpacing: 2 }}>
{stars}
</div>
<div style={{ fontSize: 52, fontWeight: 800, color: "#2563eb" }}>
${product.price.toFixed(2)}
</div>
<div
style={{
background: "#2563eb",
color: "white",
fontSize: 22,
fontWeight: 700,
padding: "14px 32px",
borderRadius: 12,
alignSelf: "flex-start",
marginTop: 8,
}}
>
Shop Now →
</div>
</div>
</div>
),
{ ...size }
);
}
8. Twitter Card Images
Twitter (now X) uses its own image tag — twitter:image — and
supports two card types:
summary— small square thumbnail (600×600px minimum)summary_large_image— large banner image (1200×628px minimum) — this is what you want for most content
Create a separate twitter-image.tsx file if you want a different
image for Twitter vs other platforms. Otherwise, Twitter falls back to the
og:image — which is fine for most apps:
// app/blog/[slug]/twitter-image.tsx
// Only create this if you want a Twitter-specific image different from OG
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const alt = "Blog post";
export const size = { width: 1200, height: 628 }; // Twitter prefers 628px height
export const contentType = "image/png";
export default async function TwitterImage({ params }: { params: { slug: string } }) {
// Twitter prefers a slightly different aspect ratio and simpler design
const post = await getPostBySlug(params.slug);
return new ImageResponse(
(
<div
style={{
background: "#0f172a",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
padding: "60px 80px",
fontFamily: "sans-serif",
justifyContent: "flex-end",
}}
>
<div style={{ fontSize: 52, fontWeight: 800, color: "white", lineHeight: 1.2 }}>
{post?.title}
</div>
<div style={{ fontSize: 22, color: "#94a3b8", marginTop: 20 }}>
acmecorp.com · {post?.author.name}
</div>
</div>
),
{ ...size }
);
}
9. Caching and Performance
OG images generated by ImageResponse are cached automatically:
- Static routes — generated once at build time and served as static files. Zero runtime cost after the build.
- Dynamic routes — generated on the first request, then cached by Next.js's Full Route Cache. Subsequent requests are served from cache.
-
Edge runtime — setting
export const runtime = "edge"means the image is generated at the edge (Vercel's CDN nodes), not at a single origin server. This gives fast generation times regardless of where the user is.
To control cache duration, set the revalidate export:
// Cache for 1 hour, then regenerate on next request
export const revalidate = 3600;
// Never cache — regenerate on every request (useful during development)
export const revalidate = 0;
10. Testing Your OG Images
Several tools let you preview and debug OG images without deploying:
Locally
While running npm run dev, OG images are accessible at their file
path. For app/blog/[slug]/opengraph-image.tsx, visit:
http://localhost:3000/blog/my-post-slug/opengraph-image
This renders and displays the image directly in the browser. Tweak the JSX and refresh to see changes instantly — much faster than deploying to test.
Social Preview Tools
- Open Graph Debugger —
developers.facebook.com/tools/debug— tests Facebook/LinkedIn previews and clears their cache - Twitter Card Validator —
cards-dev.twitter.com/validator— tests Twitter card appearance - opengraph.xyz — tests how your URL looks across multiple platforms at once
- metatags.io — live preview of OG tags across social platforms
After deploying, always run your important pages through the Facebook debugger first — Facebook aggressively caches OG images and the debugger forces a refresh of their cache.
11. Common Gotchas
-
Using Tailwind classes inside ImageResponse. They don't work —
Tailwind's stylesheet isn't loaded in the image generation context. Use inline
styleobjects with camelCase CSS properties only. -
Using CSS variables (
var(--color)) in styles. CSS variables are not supported insideImageResponse. Use literal colour values like"#2563eb"instead. -
Trying to use
display: grid. Only flexbox is supported. Recreate grid layouts using nested flex containers. -
Images not showing in production but working locally.
Make sure
metadataBaseis set in your root layout — without it, Next.js can't generate absolute URLs for the OG image, which social platforms require. - Font rendering looking wrong. The default system font varies by platform and may look different than expected. Load a specific font file for consistent rendering across all environments.
-
Title text overflowing the image. Long titles break the layout.
Use conditional font sizing based on title length — reduce
fontSizewhentitle.length > 60as shown in the blog post example above. -
Fetching images from external URLs. External images must be
accessible without authentication and served over HTTPS. Use absolute URLs
for
<img>tags insideImageResponse.
Key Takeaways
- Place
opengraph-image.pngin any route folder for a static OG image — Next.js handles the meta tag automatically. - Create
opengraph-image.tsxreturning anImageResponsefor dynamic, data-driven OG images. - The file receives the same
paramsas its siblingpage.tsx— use them to fetch post, product, or user data. - Always use inline
styleobjects anddisplay: flex— Tailwind classes and CSS grid are not supported. - Load a custom font via
ArrayBufferand thefontsoption for consistent, brand-aligned typography. - OG images are cached automatically — static at build time, dynamic after first request.
- Test locally by visiting
/route/opengraph-imagedirectly in the browser — no deployment needed. - Always set
metadataBasein the root layout — social platforms require absolute URLs for OG images.
Next up: Lesson 403 — Sitemap & robots.txt Generation. You'll learn how to
auto-generate XML sitemaps from your dynamic routes, split large sitemaps
across multiple files, and configure robots.txt to control exactly
which pages crawlers can access.