Next.js Dynamic OpenGraph Images with @vercel/og: Edge Runtime, Templates, and Caching
Generate dynamic OpenGraph images in Next.js with @vercel/og. Covers ImageResponse API, edge runtime execution, custom fonts, blog post and social card templates, cache headers, and file-based fallback images.
Sharing a link on Twitter or Slack with a blank gray preview is a missed opportunity. Dynamic OpenGraph images โ generated per-page with the right title, author, category, and branding โ dramatically increase click-through rates on social content. With @vercel/og, they generate at the edge in milliseconds using React JSX.
This guide covers templates for blog posts, landing pages, and user profiles, with caching strategies that keep your CDN bill reasonable.
How It Works
@vercel/og uses Satori (a JSX โ SVG renderer) to convert React components to images. It runs at the edge (no cold start), supports a constrained subset of CSS (Flexbox only, no Grid), and returns a PNG response.
npm install @vercel/og
Blog Post OG Image
// app/og/route.tsx
import { ImageResponse } from "@vercel/og";
import { NextRequest } from "next/server";
export const runtime = "edge";
// Load fonts at module level (cached per edge instance)
const INTER_BOLD = fetch(
new URL("../../public/fonts/Inter-Bold.ttf", import.meta.url)
).then((res) => res.arrayBuffer());
const INTER_REGULAR = fetch(
new URL("../../public/fonts/Inter-Regular.ttf", import.meta.url)
).then((res) => res.arrayBuffer());
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const title = searchParams.get("title")?.slice(0, 100) ?? "Untitled";
const description = searchParams.get("desc")?.slice(0, 160) ?? "";
const category = searchParams.get("cat") ?? "";
const author = searchParams.get("author") ?? "Viprasol Tech Team";
const readTime = searchParams.get("rt") ?? "";
const type = searchParams.get("type") ?? "post"; // post | page | profile
const [interBold, interRegular] = await Promise.all([INTER_BOLD, INTER_REGULAR]);
const CATEGORY_COLORS: Record<string, { bg: string; text: string }> = {
"web-dev": { bg: "#dbeafe", text: "#1d4ed8" },
"ai-ml": { bg: "#ede9fe", text: "#6d28d9" },
"cloud": { bg: "#d1fae5", text: "#065f46" },
"saas": { bg: "#fce7f3", text: "#9d174d" },
"fintech": { bg: "#fef3c7", text: "#92400e" },
"trading": { bg: "#fee2e2", text: "#991b1b" },
"blockchain":{ bg: "#f3f4f6", text: "#374151" },
"business": { bg: "#e0f2fe", text: "#0c4a6e" },
};
const catStyle = CATEGORY_COLORS[category] ?? { bg: "#f3f4f6", text: "#374151" };
const catLabel = category
? category.replace("-", " ").replace(/\b\w/g, (c) => c.toUpperCase())
: "";
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#ffffff",
fontFamily: "Inter",
padding: "60px",
position: "relative",
}}
>
{/* Top accent bar */}
<div style={{
position: "absolute",
top: 0, left: 0, right: 0,
height: "6px",
background: "linear-gradient(90deg, #2563eb 0%, #7c3aed 50%, #db2777 100%)",
}} />
{/* Logo */}
<div style={{
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "auto",
}}>
<div style={{
width: "36px",
height: "36px",
borderRadius: "8px",
backgroundColor: "#2563eb",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<div style={{ color: "white", fontSize: "16px", fontWeight: "700" }}>V</div>
</div>
<span style={{ fontSize: "16px", fontWeight: "600", color: "#374151" }}>
Viprasol
</span>
</div>
{/* Category badge */}
{catLabel && (
<div style={{
display: "flex",
marginBottom: "20px",
}}>
<span style={{
backgroundColor: catStyle.bg,
color: catStyle.text,
fontSize: "14px",
fontWeight: "600",
padding: "4px 14px",
borderRadius: "100px",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}>
{catLabel}
</span>
</div>
)}
{/* Title */}
<div style={{
fontSize: title.length > 60 ? "38px" : "48px",
fontWeight: "700",
color: "#111827",
lineHeight: "1.2",
marginBottom: "20px",
maxWidth: "900px",
}}>
{title}
</div>
{/* Description */}
{description && (
<div style={{
fontSize: "20px",
color: "#6b7280",
lineHeight: "1.5",
maxWidth: "800px",
marginBottom: "32px",
}}>
{description.length > 120 ? description.slice(0, 120) + "โฆ" : description}
</div>
)}
{/* Footer */}
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: "1px solid #e5e7eb",
paddingTop: "24px",
marginTop: "auto",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div style={{
width: "32px", height: "32px", borderRadius: "50%",
backgroundColor: "#dbeafe",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<span style={{ fontSize: "14px", color: "#2563eb", fontWeight: "700" }}>
{author[0]}
</span>
</div>
<span style={{ fontSize: "16px", color: "#374151" }}>{author}</span>
</div>
{readTime && (
<span style={{ fontSize: "15px", color: "#9ca3af" }}>
{readTime}
</span>
)}
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{ name: "Inter", data: interBold, weight: 700 },
{ name: "Inter", data: interRegular, weight: 400 },
],
}
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Metadata Integration
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { getPost } from "@/lib/cms";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) return {};
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
// Build OG image URL with query params
const ogImageUrl = new URL("/og", APP_URL);
ogImageUrl.searchParams.set("title", post.title);
ogImageUrl.searchParams.set("desc", post.excerpt ?? "");
ogImageUrl.searchParams.set("cat", post.category);
ogImageUrl.searchParams.set("author", post.author.name);
ogImageUrl.searchParams.set("rt", post.readTime);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt ?? undefined,
type: "article",
publishedTime: post.publishedAt,
authors: [post.author.name],
images: [{
url: ogImageUrl.toString(),
width: 1200,
height: 630,
alt: post.title,
}],
},
twitter: {
card: "summary_large_image",
title: post.title,
description: post.excerpt ?? undefined,
images: [ogImageUrl.toString()],
creator: "@viprasol",
},
};
}
Caching Strategy
OG images don't change often โ cache them aggressively:
// app/og/route.tsx โ add cache headers
export async function GET(req: NextRequest) {
// ... image generation ...
const response = new ImageResponse(/* ... */, { width: 1200, height: 630, fonts });
// Cache at CDN for 7 days, stale-while-revalidate for 30 days
response.headers.set(
"Cache-Control",
"public, immutable, no-transform, max-age=604800, stale-while-revalidate=2592000"
);
return response;
}
For Vercel: OG images are cached at the edge automatically. For self-hosted, add a CDN in front.
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Profile Card Template
// app/og/profile/route.tsx โ user profile OG card
import { ImageResponse } from "@vercel/og";
import { NextRequest } from "next/server";
export const runtime = "edge";
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const name = searchParams.get("name") ?? "User";
const title = searchParams.get("title") ?? "";
const projects = searchParams.get("projects") ?? "0";
const avatarUrl = searchParams.get("avatar") ?? "";
return new ImageResponse(
(
<div style={{
width: "100%", height: "100%",
display: "flex", flexDirection: "column", alignItems: "center",
justifyContent: "center",
backgroundColor: "#0f172a",
fontFamily: "system-ui",
gap: "16px",
}}>
{/* Gradient blob */}
<div style={{
position: "absolute",
top: "50%", left: "50%",
transform: "translate(-50%, -50%)",
width: "400px", height: "400px",
borderRadius: "50%",
background: "radial-gradient(circle, rgba(37,99,235,0.3) 0%, transparent 70%)",
}} />
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarUrl}
width={96} height={96}
style={{ borderRadius: "50%", border: "3px solid rgba(255,255,255,0.2)" }}
alt={name}
/>
) : (
<div style={{
width: "96px", height: "96px", borderRadius: "50%",
backgroundColor: "#2563eb",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "40px", color: "white", fontWeight: "700",
}}>
{name[0]}
</div>
)}
<div style={{ color: "white", fontSize: "36px", fontWeight: "700" }}>
{name}
</div>
{title && (
<div style={{ color: "#94a3b8", fontSize: "20px" }}>{title}</div>
)}
<div style={{
display: "flex", gap: "24px", marginTop: "8px",
}}>
<div style={{ textAlign: "center" }}>
<div style={{ color: "white", fontSize: "24px", fontWeight: "700" }}>
{projects}
</div>
<div style={{ color: "#64748b", fontSize: "14px" }}>Projects</div>
</div>
</div>
</div>
),
{ width: 1200, height: 630 }
);
}
File-Based Fallback
For pages without dynamic OG images, use a static fallback:
// app/layout.tsx โ default OG image
export const metadata: Metadata = {
openGraph: {
images: ["/og-default.png"], // Static file in /public
},
};
Satori CSS Constraints
Things that work in @vercel/og:
- Flexbox (
display: "flex",flexDirection,alignItems,justifyContent) position: "absolute"withtop,left,right,bottomborder,borderRadius,borderColorbackground,backgroundColor,backgroundImage(gradients via CSS string)- Custom fonts (TTF, OTF via fetch)
<img>elements with absolute URLs
Things that don't work:
- CSS Grid (
display: "grid") - CSS variables (
var(--color)) overflow: "hidden"(partially supported)- Web fonts via Google Fonts URL (must fetch TTF directly)
- SVG
<image>tags
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Single OG template (blog post) | 1 dev | 0.5โ1 day | $150โ300 |
| Full set (post + page + profile + social) | 1 dev | 2โ3 days | $400โ800 |
| Design-matched templates + custom fonts | 1 dev | 3โ5 days | $800โ1,500 |
Cost to run: Essentially free on Vercel (edge invocations) and cached at CDN. On self-hosted, each image is ~10โ50ms CPU and cached after first render.
See Also
- Next.js Performance Optimization
- Next.js Static Generation with ISR
- Next.js Draft Mode for CMS Previews
- Next.js Edge Runtime Patterns
- Next.js App Router Caching Strategies
Working With Viprasol
Dynamic OG images consistently improve social share click-through rates โ posts with branded, content-specific preview images perform 2โ3ร better than those with generic images or blank previews. Our team designs and implements @vercel/og templates that match your brand, with custom fonts, category colors, and metadata pulled directly from your CMS.
What we deliver:
- Blog post OG image with title, description, category badge, and author
- Profile card template with avatar and stats
- Metadata helpers for
generateMetadata()in App Router - Proper Cache-Control headers for CDN caching
- Satori-compatible Flexbox layout (tested across card sizes)
Talk to our team about your social sharing setup โ
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.