# What You'll Build and Why It Matters#
Dynamic Open Graph images let every blog post, product, or landing page render a unique social preview without designing hundreds of assets. When the preview matches the page title, category, and author, it improves share-through rate and reduces “generic preview” impressions that look spammy.
This guide shows a production-ready approach for Next.js dynamic Open Graph images using the App Router and next/og, including font handling, caching headers, Edge runtime constraints, and local versus deployment parity.
If you are still setting up your project structure, start with Getting started with Next.js. For SEO context on why these previews matter, see Why Next.js for SEO. For deeper caching patterns that connect directly to OG generation, read Next.js caching strategies: SSR, ISR, SWR.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Next.js | 14 or 15 | App Router recommended |
| Node.js | 18+ | Local dev and build tooling |
| Deploy target | Vercel or similar | Edge runtime optional |
| Fonts | WOFF or TTF | Commit to repo for parity |
| Content source | MDX, CMS, DB | Needs stable slug and title |
ℹ️ Note:
next/ogruns in a server environment. You cannot call browser-only APIs. If you run on Edge, you also cannot use many Node.js built-ins.
# How Dynamic OG Image Generation Works in Next.js#
In the App Router, you typically expose a dedicated route that returns an image response. The page metadata points openGraph.images to that route, usually with a slug parameter.
There are two common patterns:
| Pattern | URL example | Pros | Cons |
|---|---|---|---|
| One OG route for many items | /api/og?slug=my-post | Simple, flexible | Query parameters can complicate caching |
| Route segment per item | /og/my-post.png | Stable URLs, easier caching | Requires route segment setup |
For performance and caching, prefer stable, versionable URLs. Social scrapers and CDNs cache aggressively, so it is better if /og/my-post.png?v=2026-05-25 changes when content changes.
# Step 1: Create an OG Route with next/og#
Create a route handler that returns an image. A common approach is:
app/og/[slug]/route.tsfor per-item routes- Use
ImageResponsefromnext/og - Fetch your content title and any metadata you want to render
// app/og/[slug]/route.ts
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export async function GET(
_req: Request,
context: { params: Promise<{ slug: string }> }
) {
const { slug } = await context.params;
const title = await getTitleBySlug(slug);
return new ImageResponse(
(
<div
style={{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '64px',
background: '#0B1020',
color: 'white',
}}
>
<div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.1 }}>
{title}
</div>
<div style={{ marginTop: 24, fontSize: 28, opacity: 0.8 }}>
samioda.com
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
}
async function getTitleBySlug(slug: string) {
// Replace with CMS/DB/MDX lookup
return `Post: ${slug}`;
}This renders a PNG at runtime. Next.js will run it server-side, and if deployed behind a CDN it can be cached.
💡 Tip: Always stick to
1200x630for OG previews. It matches the most common scrapers and avoids unexpected cropping.
# Step 2: Wire the OG Image Into Page Metadata#
Use generateMetadata in your page route. Your goal is to keep the OG image URL deterministic for the page.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const title = await getPostTitle(slug);
const updatedAt = await getPostUpdatedAtISO(slug);
const ogUrl = `/og/${slug}?v=${encodeURIComponent(updatedAt)}`;
return {
title,
openGraph: {
title,
images: [{ url: ogUrl, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title,
images: [ogUrl],
},
};
}
async function getPostTitle(slug: string) {
return `Blog: ${slug}`;
}
async function getPostUpdatedAtISO(slug: string) {
// Use a real updatedAt from CMS, git history, or DB.
return new Date().toISOString().slice(0, 10);
}Why versioning matters#
Even if your CDN respects cache headers, platforms like Facebook, X, Slack, and LinkedIn may keep their own caches. Updating content without changing the OG image URL is a common reason “it still shows the old image.”
Versioning makes the update explicit.
🎯 Key Takeaway: Treat OG image URLs like static assets. Stable, cacheable, and versioned beats “always dynamic” for real-world scraper behavior.
# Step 3: Fonts That Match Local and Production#
The most frequent production-only OG bug is font fallback. Local dev often has fonts installed on your machine, while Edge environments do not.
Recommended font strategy#
- 1Put the font files in your repo, for example
app/og/_assets/Inter-SemiBold.ttf - 2Load them with
fetchusingnew URL(..., import.meta.url) - 3Pass the loaded font bytes to
ImageResponse
// app/og/[slug]/route.ts
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
const interSemiBold = fetch(
new URL('../_assets/Inter-SemiBold.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
export async function GET(
_req: Request,
context: { params: Promise<{ slug: string }> }
) {
const { slug } = await context.params;
const title = `Post: ${slug}`;
const fontData = await interSemiBold;
return new ImageResponse(
(
<div
style={{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '64px',
background: '#0B1020',
color: '#FFFFFF',
fontFamily: 'Inter',
}}
>
<div style={{ fontSize: 60, fontWeight: 600, lineHeight: 1.1 }}>
{title}
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: fontData,
weight: 600,
style: 'normal',
},
],
}
);
}Font format notes#
| Font format | Works well with next/og | File size | Notes |
|---|---|---|---|
| TTF | Yes | Medium | Most common in examples |
| OTF | Sometimes | Medium | Can fail depending on glyph tables |
| WOFF/WOFF2 | Not ideal | Small | Often needs conversion for server render |
If you need WOFF2 in your site but TTF for OG images, keep both. The OG route is its own rendering pipeline.
⚠️ Warning: Do not rely on
next/fontin OG routes.next/ogneeds raw font bytes, not CSS-injected font faces.
# Step 4: Caching Headers That Actually Work#
OG images are perfect candidates for CDN caching. The image is expensive to generate compared to serving from cache, and it is requested repeatedly by scrapers.
A practical caching policy#
Use cache headers with a long CDN cache and reasonable stale window.
publicmakes it cacheables-maxagetargets CDNsstale-while-revalidateallows fast responses while refreshing in the background
// app/og/[slug]/route.ts
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export async function GET(
_req: Request,
context: { params: Promise<{ slug: string }> }
) {
const { slug } = await context.params;
const res = new ImageResponse(
(
<div style={{ width: '1200px', height: '630px', background: '#0B1020' }}>
<div style={{ color: 'white', padding: 64, fontSize: 56 }}>
{slug}
</div>
</div>
),
{ width: 1200, height: 630 }
);
res.headers.set(
'Cache-Control',
'public, s-maxage=2592000, stale-while-revalidate=86400'
);
return res;
}That policy caches for 30 days at the edge and allows a 24-hour stale period. If you also version the URL with ?v=updatedAt, you get high cache hit rates without showing outdated previews.
If you want to align with ISR revalidation, use a smaller s-maxage, for example 1 to 6 hours, and keep versioning.
For broader caching concepts and tradeoffs, see Next.js caching strategies: SSR, ISR, SWR.
CDN and scraper caching are different#
Even perfect Cache-Control headers do not guarantee immediate updates in social previews. Many scrapers cache for hours or days. Your best lever is URL versioning, not just cache headers.
# Step 5: Data Fetching for OG Routes Without Surprises#
The OG route usually needs:
- title
- category
- author
- publish date
- maybe a product price or badge
Minimize external calls. One API request per OG render is acceptable if cached, but multiple requests increase cold-start time and failure points.
Suggested data contract#
| Field | Example | Source | Rendered in OG |
|---|---|---|---|
| slug | nextjs-og-images | URL param | Optional |
| title | Dynamic OG Images in Next.js | CMS/MDX | Yes |
| updatedAt | 2026-05-25 | CMS/DB | Used for ?v= |
| tag | Next.js | CMS/MDX | Yes |
| author | Adrijan Omićević | CMS/MDX | Optional |
If you already compute metadata for the page, avoid duplicating logic by moving the content lookup into a shared server-only module and reuse it both in generateMetadata and in the OG route.
💡 Tip: If your CMS is slow, cache the content lookup separately from the image bytes. The fastest OG route is the one that does not hit the CMS on most requests.
# Step 6: Edge Runtime Tips and When to Use Node Runtime#
Edge runtime is attractive because it places generation close to the user and can reduce latency. It also has sharp constraints.
Edge runtime checklist#
| Concern | Edge runtime status | What to do |
|---|---|---|
Node.js modules like fs | Not available | Use fetch and bundle assets in repo |
| Large dependencies | Risky | Keep OG route minimal |
| Sharp or canvas libs | Not supported | Use next/og rendering only |
| Network egress to private DB | Often blocked | Use public APIs or a cached layer |
| Cold starts | Generally low | Still keep the route small |
If your OG generation needs private network access to a database, consider running the OG route on Node runtime instead, or proxy through an API designed for Edge access.
To switch, remove export const runtime = 'edge'; or set it to Node, depending on your Next.js version and deployment environment capabilities.
# Step 7: Local and Dev Parity#
A frequent team workflow issue is “looks fine locally, broken on preview deployment.” Fix parity by making local behave like production:
- Always load fonts from repo assets.
- Avoid
localhostabsolute URLs in metadata. - Use environment variables for
SITE_URLwhen you must generate absolute URLs.
Absolute versus relative OG URLs#
Most platforms accept absolute URLs reliably. Relative URLs may work in some contexts but can fail depending on the scraper.
Use an absolute base URL derived from environment variables.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
function siteUrl() {
const url = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
return url.replace(/\/$/, '');
}
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const updatedAt = new Date().toISOString().slice(0, 10);
const ogPath = `/og/${slug}?v=${encodeURIComponent(updatedAt)}`;
const ogAbsolute = `${siteUrl()}${ogPath}`;
return {
openGraph: {
images: [{ url: ogAbsolute, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
images: [ogAbsolute],
},
};
}⚠️ Warning: Do not accidentally ship
http://localhost:3000into production metadata. ValidateNEXT_PUBLIC_SITE_URLin CI, or set it at the platform level.
# Step 8: Performance Considerations That Move the Needle#
OG rendering can become a hidden cost when:
- a post goes viral and the OG route gets hammered
- scrapers request the OG image multiple times
- you have multi-tenant or personalized OG rendering
Practical performance levers#
| Lever | Typical impact | How to implement |
|---|---|---|
| Cache headers | High | s-maxage plus stale-while-revalidate |
| URL versioning | High | ?v=updatedAt or content hash |
| Reduce external calls | Medium to high | Fetch once, avoid chaining API calls |
| Keep render tree simple | Medium | Avoid huge inline SVGs or large images |
| Precompute “title lines” | Low to medium | Truncate and wrap predictably |
Text layout and truncation#
The most common visual bug is overflow or clipped titles. Decide a rule and enforce it, for example:
- maximum 90 characters
- replace consecutive whitespace
- fallback title if empty
Do the cleanup before rendering.
# Troubleshooting Common Deployment Issues#
These issues show up repeatedly when deploying Next.js OG generation to Vercel, Cloudflare, or a container platform.
1) Blank image or 500 error only in production#
Typical causes:
- Font load failing due to path or bundling
- Using Node APIs in Edge runtime
- A dependency that is not compatible with Edge
Fixes:
- Bundle fonts under
app/og/_assetsand load vianew URL(..., import.meta.url) - Remove Node-only code from the OG route
- Temporarily switch to Node runtime to confirm whether Edge constraints are the cause
2) “Unexpected token” or build errors after adding OG markup#
Typical cause:
- Accidental JSX or MDX parsing issues elsewhere, often from invalid characters or tooling
Fixes:
- Keep the OG route as a TypeScript route handler under
app - Avoid dynamic imports that pull client components into the route bundle
- Keep the OG route isolated with minimal dependencies
3) OG image is outdated even after redeploy#
Typical causes:
- Social platform cache
- CDN cache with a long TTL and no versioning
- Reused URL without a content version
Fixes:
- Version the URL with
?v=updatedAt - Reduce
s-maxageif you cannot version - Use platform debugging tools to force re-scrape
4) Fonts look correct locally but wrong on Vercel#
Typical causes:
- Local OS font fallback masks missing font bundling
- Missing weights, for example 700 requested but only 400 loaded
Fixes:
- Load font bytes explicitly and provide the right
weight - Add multiple font weights if you use them in the OG layout
5) Slow OG responses on first request#
Typical causes:
- Cold cache
- Heavy CMS call
- Large font file or multiple fonts
Fixes:
- Cache aggressively and version URLs
- Use one font weight where possible
- Reduce CMS calls and use a small, cached API response for OG data
# Key Takeaways#
- Generate per-page previews with
next/ogand route handlers like/og/[slug], then reference them ingenerateMetadata. - Bundle fonts in your repo and pass raw font bytes to
ImageResponseto avoid local-only font fallbacks. - Use
Cache-Controlwiths-maxageandstale-while-revalidate, and combine it with URL versioning like?v=updatedAt. - Prefer stable OG URLs per item to maximize CDN cache hits and reduce scraper inconsistencies.
- For Edge runtime, avoid Node.js APIs and keep the OG route dependency graph minimal to prevent deployment-only failures.
# Conclusion#
Dynamic OG previews are one of the highest ROI SEO and sharing improvements you can ship in a Next.js app, because every page gets a tailored visual without design overhead. Implement the OG route with next/og, bundle and load fonts explicitly, and treat caching and URL versioning as part of the feature, not an afterthought.
If you want Samioda to implement Next.js dynamic Open Graph images with production-grade caching, Edge-safe rendering, and CMS integration, contact us via samioda.com and we will ship a setup that behaves the same locally, in preview, and in production.
FAQ
More in Web Development
All →Next.js Technical SEO Audit Checklist (App Router): Indexing, Metadata, Core Web Vitals, and Structured Data
A step-by-step Next.js technical SEO audit checklist for the App Router: crawling and indexing controls, metadata and canonicals, sitemaps and robots, pagination, Core Web Vitals, and JSON-LD schema with copy-pasteable code.
Building a React Design System with Design Tokens: Tailwind CSS + Radix UI + TypeScript
A practical 2026 guide to building a React design system with Tailwind and Radix using design tokens, accessible primitives, theming, and reusable packages across multiple apps.
Next.js + Supabase SaaS Starter Architecture (App Router): Auth, RLS, Billing, and Multi-Tenancy
A production-ready blueprint for a Next.js App Router + Supabase SaaS starter architecture: auth, Postgres data model, RLS policies, Stripe billing, and multi-tenant organization design with concrete examples.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Website Performance Optimization: The Complete Checklist (Next.js + Core Web Vitals) for 2026
A practical, production-ready checklist for website performance optimization in Next.js: Core Web Vitals, images, lazy loading, CDN, and caching—plus before/after metrics and copy-paste config.
Next.js Technical SEO Audit Checklist (App Router): Indexing, Metadata, Core Web Vitals, and Structured Data
A step-by-step Next.js technical SEO audit checklist for the App Router: crawling and indexing controls, metadata and canonicals, sitemaps and robots, pagination, Core Web Vitals, and JSON-LD schema with copy-pasteable code.
React Query vs SWR in Next.js App Router: When to Use Which (and How to Avoid Double Fetching)
A practical 2026 comparison of React Query and SWR inside Next.js App Router — caching models, SSR and RSC compatibility, mutations, optimistic updates, DX, and proven patterns to prevent double fetching.