Web Development
Next.jsSEOOpen GraphPerformanceEdge RuntimeCaching

Dynamic Open Graph Images in Next.js: OG Generation, Caching, Fonts, and Edge Runtime Tips

AO
Adrijan Omićević
·13 min read

# 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#

RequirementVersionNotes
Next.js14 or 15App Router recommended
Node.js18+Local dev and build tooling
Deploy targetVercel or similarEdge runtime optional
FontsWOFF or TTFCommit to repo for parity
Content sourceMDX, CMS, DBNeeds stable slug and title

ℹ️ Note: next/og runs 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:

PatternURL exampleProsCons
One OG route for many items/api/og?slug=my-postSimple, flexibleQuery parameters can complicate caching
Route segment per item/og/my-post.pngStable URLs, easier cachingRequires 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.ts for per-item routes
  • Use ImageResponse from next/og
  • Fetch your content title and any metadata you want to render
TypeScript
// 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 1200x630 for 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.

TypeScript
// 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.

  1. 1
    Put the font files in your repo, for example app/og/_assets/Inter-SemiBold.ttf
  2. 2
    Load them with fetch using new URL(..., import.meta.url)
  3. 3
    Pass the loaded font bytes to ImageResponse
TypeScript
// 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 formatWorks well with next/ogFile sizeNotes
TTFYesMediumMost common in examples
OTFSometimesMediumCan fail depending on glyph tables
WOFF/WOFF2Not idealSmallOften 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/font in OG routes. next/og needs 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.

  • public makes it cacheable
  • s-maxage targets CDNs
  • stale-while-revalidate allows fast responses while refreshing in the background
TypeScript
// 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#

FieldExampleSourceRendered in OG
slugnextjs-og-imagesURL paramOptional
titleDynamic OG Images in Next.jsCMS/MDXYes
updatedAt2026-05-25CMS/DBUsed for ?v=
tagNext.jsCMS/MDXYes
authorAdrijan OmićevićCMS/MDXOptional

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#

ConcernEdge runtime statusWhat to do
Node.js modules like fsNot availableUse fetch and bundle assets in repo
Large dependenciesRiskyKeep OG route minimal
Sharp or canvas libsNot supportedUse next/og rendering only
Network egress to private DBOften blockedUse public APIs or a cached layer
Cold startsGenerally lowStill 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 localhost absolute URLs in metadata.
  • Use environment variables for SITE_URL when 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.

TypeScript
// 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:3000 into production metadata. Validate NEXT_PUBLIC_SITE_URL in 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#

LeverTypical impactHow to implement
Cache headersHighs-maxage plus stale-while-revalidate
URL versioningHigh?v=updatedAt or content hash
Reduce external callsMedium to highFetch once, avoid chaining API calls
Keep render tree simpleMediumAvoid huge inline SVGs or large images
Precompute “title lines”Low to mediumTruncate 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/_assets and load via new 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-maxage if 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/og and route handlers like /og/[slug], then reference them in generateMetadata.
  • Bundle fonts in your repo and pass raw font bytes to ImageResponse to avoid local-only font fallbacks.
  • Use Cache-Control with s-maxage and stale-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

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.