ScreenshotNeo

BlogHow-to

How to Generate Dynamic OG Images for Blog Posts, Product Pages, and User Profiles

Generate route-specific Open Graph images in Next.js with reusable templates, real data, and reliable caching. Includes code, edge cases, and a screenshot API alternative.

By the ScreenshotNeo team4 October 202613 min read

In Next.js App Router, add an opengraph-image.tsx file to the route segment that owns the content, load that route’s data, and return an ImageResponse containing a reusable JSX template. The route parameters supply the title, product details, or profile fields; the template controls the shared brand layout. Next.js documents this pattern for blog posts and a 1200 × 630 PNG output. The same route-data approach applies to product and profile routes.

This guide uses the App Router and a small in-memory data source so the example runs without a database. Replace the data lookup with your existing content layer. For image metadata behavior and configuration, see the Next.js metadata image guide and Metadata and OG images.

1. Choose a route structure and image URL

Next.js discovers metadata files by route segment. A file under app/blog/[slug]/ can generate an image for each blog slug, while corresponding files under product or profile segments can use those routes’ data. A more specific route image takes precedence over an image in a parent segment.

app/
  blog/
    [slug]/
      opengraph-image.tsx
  products/
    [slug]/
      opengraph-image.tsx
  users/
    [username]/
      opengraph-image.tsx

The image URL is derived from the route metadata convention, so you usually do not need to hardcode it in each page’s metadata. If you want to set it explicitly, use the route-specific URL or metadata file reference supported by your Next.js version. Verify the generated metadata on the rendered page before sharing it.

2. Build the blog post image generator

Create app/blog/[slug]/opengraph-image.tsx. The example uses an inline data map to make the route and render flow concrete. In a production app, replace getPost with a database, CMS, or content-file lookup. ImageResponse renders JSX and a supported subset of CSS; use flexbox and simple styles for predictable layouts.

import { ImageResponse } from 'next/og';

export const runtime = 'edge';
export const alt = 'Blog post social preview';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

type Post = {
  title: string;
  category: string;
  author: string;
};

const posts: Record<string, Post> = {
  'dynamic-og-images': {
    title: 'How to Generate Dynamic OG Images',
    category: 'Engineering',
    author: 'Alex Morgan',
  },
  'edge-cache-guide': {
    title: 'A Practical Guide to Edge Caching',
    category: 'Performance',
    author: 'Sam Lee',
  },
};

async function getPost(slug: string): Promise<Post | null> {
  return posts[slug] ?? null;
}

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    return new ImageResponse(
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          background: '#111827',
          color: 'white',
          fontSize: 42,
        }}
      >
        Article not found
      </div>,
      size,
    );
  }

  return new ImageResponse(
    <div
      style={{
        width: '100%',
        height: '100%',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'space-between',
        padding: 64,
        background: '#101827',
        color: '#f9fafb',
        fontFamily: 'sans-serif',
      }}
    >
      <div
        style={{
          display: 'flex',
          color: '#93c5fd',
          fontSize: 24,
          fontWeight: 700,
        }}
      >
        {post.category}
      </div>
      <div
        style={{
          display: 'flex',
          maxWidth: 1040,
          fontSize: 64,
          fontWeight: 800,
          lineHeight: 1.12,
          letterSpacing: -2,
        }}
      >
        {post.title}
      </div>
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          color: '#cbd5e1',
          fontSize: 24,
        }}
      >
        <span>{post.author}</span>
        <span>example.com</span>
      </div>
    </div>,
    { ...size },
  );
}

Depending on your Next.js version, route parameter typing may differ; use the type expected by your installed version. Current App Router conventions commonly expose dynamic route parameters as a promise. Keep the output size and content type aligned with the actual response.

Keep the visual template reusable

For multiple content types, extract the shared visual component and pass it normalized fields such as title, eyebrow, subtitle, and an optional image URL. Keep route-specific data loading in each metadata file, or centralize it in a helper. This allows product and profile images to share typography, spacing, and brand colors without forcing their data models to be identical.

ImageResponse supports common CSS features, custom fonts, text wrapping, and flexbox, but it does not implement all browser CSS. The Next.js guide identifies CSS Grid as an example of an advanced layout feature that is unsupported. Design with the renderer’s supported subset rather than assuming that a browser screenshot will look identical.

3. Adapt the pattern for product pages and profiles

Use the same route-segment file and template, but fetch the data by that route’s identifier. The snippets below show the key changes; put the lookup in the corresponding segment file and render a JSX layout similar to the blog example.

Product route

// app/products/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

const products = {
  'field-notes': {
    name: 'Field Notes',
    description: 'A compact notebook for everyday ideas.',
    priceLabel: '$12',
    color: '#14532d',
  },
};

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const product = products[slug as keyof typeof products];
  const title = product?.name ?? 'Product unavailable';

  return new ImageResponse(
    <div style={{
      width: '100%', height: '100%', display: 'flex',
      flexDirection: 'column', justifyContent: 'space-between',
      padding: 64, background: product?.color ?? '#334155', color: 'white',
    }}>
      <div style={{ display: 'flex', fontSize: 24 }}>Product</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
        <div style={{ display: 'flex', fontSize: 68, fontWeight: 800 }}>{title}</div>
        <div style={{ display: 'flex', fontSize: 28 }}>
          {product?.description ?? 'Explore this product.'}
        </div>
      </div>
      <div style={{ display: 'flex', fontSize: 26 }}>
        {product?.priceLabel ?? 'View details'}
      </div>
    </div>,
    size,
  );
}

In a real catalog, avoid showing a stale price in a share image. Decide whether the image should reflect current product data or remain cached as a stable preview, and handle unavailable, unpublished, or region-specific products deliberately.

User profile route

// app/users/[username]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

const profiles = {
  'jordan-lee': {
    name: 'Jordan Lee',
    handle: '@jordan-lee',
    bio: 'Designer and maker.',
  },
};

export default async function Image({
  params,
}: {
  params: Promise<{ username: string }>;
}) {
  const { username } = await params;
  const profile = profiles[username as keyof typeof profiles];

  return new ImageResponse(
    <div style={{
      width: '100%', height: '100%', display: 'flex',
      flexDirection: 'column', justifyContent: 'center', gap: 20,
      padding: 72, background: '#172554', color: 'white',
    }}>
      <div style={{ display: 'flex', fontSize: 26, color: '#bfdbfe' }}>Profile</div>
      <div style={{ display: 'flex', fontSize: 72, fontWeight: 800 }}>
        {profile?.name ?? 'Member'}
      </div>
      <div style={{ display: 'flex', fontSize: 30 }}>
        {profile?.handle ?? '@member'}
      </div>
      <div style={{ display: 'flex', fontSize: 26, color: '#dbeafe' }}>
        {profile?.bio ?? 'View this profile.'}
      </div>
    </div>,
    size,
  );
}

Only include profile fields that are meant to be public. A missing photo should fall back to a deliberate neutral visual; do not make the whole image fail because an optional avatar is unavailable. Escape or render user-provided values as text, enforce sensible lengths, and avoid putting private account data into a publicly shareable image.

4. Set metadata and control freshness

Metadata files integrate with Next.js metadata conventions. You can also return metadata explicitly from a page when that better fits your app. A minimal page example is:

// 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 post = await getPost(slug);

  return {
    title: post?.title ?? 'Article',
    openGraph: {
      title: post?.title ?? 'Article',
      images: [`https://example.com/blog/${slug}/opengraph-image`],
    },
  };
}

If you use the metadata file convention, Next.js can include the generated file automatically; explicit metadata is useful when you need a custom URL or multiple image entries. Avoid defining conflicting parent and child metadata without checking which value wins.

Generated metadata images are statically optimized by default unless Dynamic APIs or uncached data make them dynamic. That affects freshness: a changed title or product price may not appear immediately if the output is cached. Choose a policy based on the data:

  • Mostly stable posts: static generation or a revalidation window can avoid repeated rendering.
  • Frequently changing product or profile data: use an appropriate dynamic or revalidated data strategy so updates become visible when expected.
  • Private or personalized data: do not expose it through a publicly cacheable metadata image. Public social previews should be derived from public fields.
  • Renamed or deleted routes: decide whether to return a neutral fallback image or a not-found response, and ensure page metadata agrees with the route state.

Use the caching behavior supported by your Next.js version and data source; caching APIs and defaults can change between framework versions.

5. Handle titles, fonts, and remote assets safely

  • Long titles: constrain the text container, allow wrapping, and reduce font size for unusually long strings. Test the longest real titles, not only a short sample.
  • Special characters: include accented characters, punctuation, emoji, and non-Latin scripts in representative checks. Supply a font that contains the glyphs you need.
  • Custom fonts: load font bytes using a supported method and pass them through the ImageResponse options. Keep font files small enough for your deployment limits and verify the renderer’s support.
  • Remote images: validate and allowlist remote hosts, use stable image URLs, and provide a fallback for missing or slow assets. Do not assume every remote format or response header will work.
  • Untrusted route values: validate identifiers, cap displayed text, and query data using parameterized APIs. Do not concatenate route values into raw SQL or expose secrets through the image.
  • Unknown records: return a designed fallback or a 404 response consistently. A fallback is often useful for deleted content, but avoid generating a misleading image that implies the record exists.

6. Choose dimensions and formats

The Next.js documentation example uses 1200 × 630 pixels and PNG. Next.js metadata image conventions support JPG/JPEG, PNG, and GIF. Its documented maximum file sizes are 8 MB for Open Graph images and 5 MB for Twitter images. These are Next.js documentation limits, not guarantees that every social platform will accept every image identically.

Cloudinary’s Next.js documentation describes a default output of 1200 × 627 pixels, corresponding to a 1.91:1 ratio. Treat that as Cloudinary’s documented default rather than a universal platform rule. If using its workflow, App Router uses getCldOgImageUrl; the Pages Router uses CldOgImage. See Cloudinary’s Next.js image transformations documentation and Next.js integration guide.

Pick one design size for the app, then check the target platforms and your own metadata requirements. Compress or simplify large assets if the output exceeds limits. A generated PNG with a large embedded photograph can be much larger than a simple graphic.

7. Preview the image and verify metadata

  1. Start the app and open a route such as /blog/dynamic-og-images.
  2. Open that route’s generated Open Graph image URL in a browser and confirm the response is an image with the intended dimensions and content.
  3. Inspect the page’s rendered metadata and confirm og:image points to the expected image, with an absolute, publicly reachable URL where required by your deployment.
  4. Repeat for a product route and a profile route, including an unknown slug or username.
  5. After changing route data, check whether the cached image updates according to your chosen freshness policy.
  6. Preview the page in the sharing/debugging tools for the services you target. Their caches may retain an older preview even after your origin serves a new image.

Use representative cases: a very long title, absent avatar, missing product image, unusual punctuation, unavailable record, and a data update. The source documentation establishes the rendering path; these checks are practical safeguards for your particular data and deployment.

8. Troubleshooting

Symptom Likely cause Fix
Image route returns an error Data lookup throws, route parameter type is wrong, or the renderer cannot process the JSX/CSS. Check server logs; handle missing data; match parameter typing to your Next.js version; simplify styles to supported flexbox and basic CSS.
Layout is missing or differs from the page ImageResponse supports a CSS subset, not full browser layout. Remove unsupported layout features such as CSS Grid and rebuild with supported styles. Do not rely on browser-only CSS behavior.
Title is clipped or unreadable Text is longer than the template anticipated or the selected font lacks glyphs. Allow wrapping, cap or adapt font size, choose a font with the required glyphs, and test long and multilingual values.
Image shows old content Static optimization, cached data, or a social platform’s preview cache. Review route and data caching/revalidation, then request a fresh preview in the relevant platform’s tooling. Confirm the origin image itself is current.
Social preview has no image Metadata URL is absent, relative where an absolute URL is needed, inaccessible, or points at an error response. Inspect rendered og:image, verify the URL from outside the app, and confirm it returns the image with an image content type.
Remote avatar or product art is missing Remote host is inaccessible, URL expires, image is too large, or fetching is unsupported in the deployed environment. Use a stable public asset URL, allowlist known hosts, reduce asset size, and render a fallback when loading fails.
Image rejected or preview inconsistent File size, format, dimensions, or platform-specific acceptance/caching behavior. Check the documented Next.js size limits, use a supported format, and verify the platform’s current requirements independently.
Build or deployment cannot bundle font/data dependency Unsupported package or asset-loading path in the image runtime. Keep dependencies compatible with the configured runtime and load only the bytes/data needed by the renderer.

9. Performance, reliability, and cost considerations

There is no single cost or speed result established for this approach; actual behavior depends on your deployment, data source, rendering, caching, and asset size. Plan around these factors:

  • Rendering work: keep templates simple and avoid unnecessary remote asset fetches. A single text-and-color design is generally less work than composing several remote images, though measure your deployment if response time is important.
  • Cache reuse: stable post data can benefit from static optimization or revalidation. Dynamic generation improves freshness when configured appropriately but can repeat data access and rendering.
  • Data dependencies: a slow CMS or database can delay image generation. Use your app’s caching and timeout strategy, and define what happens when the source is unavailable.
  • Asset reliability: remote fonts and photos add failure points. Prefer dependable assets and graceful fallbacks.
  • Output size: large graphics can increase transfer time and breach documented file-size limits. Keep layouts economical and inspect output size.
  • Operational cost: deployment and any underlying data or image services determine cost. The available documentation does not establish a universal price or performance ranking between framework rendering and managed transformations.

10. When to use Cloudinary instead

If your project already uses Cloudinary or you want URL-based image transformations and delivery, its Next.js helpers are another documented option. The App Router helper is getCldOgImageUrl; the Pages Router component is CldOgImage. Compare the paths by integration with your router, how route data reaches the template, whether ImageResponse’s CSS subset fits the design, and whether a managed transformation workflow fits your stack. The cited documentation does not support a general price, speed, or quality ranking, so choose based on the workflow and requirements you can verify.

11. Or skip the browser setup

For checking how a page renders before you build or debug its OG image, ScreenshotNeo is a website screenshot API and MCP server for developers. It does not generate route-specific branded OG artwork; it captures a rendered web page as PNG, JPEG, WebP, or PDF. Use your Next.js template when you need a designed social card, and use a screenshot when you need a faithful capture of the page itself.

One GET request returns the capture. See the ScreenshotNeo API documentation for request options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
  • Cookie and consent banners, newsletter popups, and chat widgets are removed before the capture; each cleanup step can be turned off.
  • Bot checks/CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing. Responses identify the page verdict and billing status in headers.
  • An MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs.
  • The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots.

Sign up free for 1,000 screenshots a month, with no card required.

12. FAQ

Can one template serve blog posts, products, and profiles?

Yes. Normalize the values the design needs and keep each route’s data lookup separate. Use optional fields and sensible fallbacks for differences such as product pricing or profile handles.

Does this produce the same image as taking a browser screenshot?

No. ImageResponse renders a JSX/CSS design through its renderer. A browser screenshot captures a rendered webpage. Choose based on whether you need a designed social card or a page capture.

Can I use the approach in the Pages Router?

The route-segment opengraph-image.tsx convention described here is for the App Router. For a Pages Router workflow, Cloudinary documents its CldOgImage helper; otherwise consult the current framework documentation for the approach supported by your app.

Should I put the author’s photo in every blog image?

Only if it helps identify the content and the asset is reliably available. A clear fallback keeps a missing or changed photo from breaking image generation.