ScreenshotNeo

BlogHow-to

How to Generate Dynamic Open Graph Images With Live Page Screenshots in Next.js

Capture a rendered Next.js page for Open Graph previews with Playwright, metadata routes, caching, troubleshooting, and a no-browser ScreenshotNeo option.

By the ScreenshotNeo team29 September 20269 min read

How to Generate Dynamic Open Graph Images With Live Page Screenshots in Next.js

Short answer: point openGraph.images at a public Next.js route that captures an allowlisted page with Playwright, then returns PNG bytes. A social crawler fetches that URL directly; it does not run your page’s client-side JavaScript. Use next/og and ImageResponse when you want a designed card from JSX. Use a browser screenshot when the image must match the live, rendered page.

Vercel recommends 1200×630 pixels for Open Graph images. Playwright lets you choose the viewport, full-page or element capture, and output buffer, so you can make that size deterministic. [Vercel OG image guidance](https://vercel.com/docs/og-image-generation) and [Playwright screenshot API](https://playwright.dev/docs/screenshots) document the two approaches.

1. Choose a live screenshot or a generated card

Approach What it renders Use it when Constraints
ImageResponse Supported JSX/HTML-like markup You need a branded title, author, date, logo or data card Limited CSS (flexbox is supported; CSS grid is not), TTF/OTF/WOFF fonts, and a 500 KB bundle limit
Playwright The browser-rendered page, viewport or element Fidelity to the actual page is the requirement Needs a browser-capable server, deterministic readiness, resource loading and caching

Vercel documents that App Router projects include @vercel/og, with its current Next.js implementation requirements listed on the documentation page. Those requirements apply to generated markup images; a full browser process has different memory, binary and execution-time needs. Verify your hosting runtime before deploying Playwright.

2. Build a safe screenshot route

The route below accepts a content slug, resolves it to your own origin, launches Chromium, waits for an app-specific marker, and returns a 1200×630 PNG. Do not accept an arbitrary URL from an unauthenticated query string: that turns your endpoint into an SSRF proxy. Add a version key when content changes so caches can be invalidated.

A crawler fetches a public metadata image URL while the server performs the browser capture.
A crawler fetches a public metadata image URL while the server performs the browser capture.

Install Playwright

npm install playwright
npx playwright install chromium

Keep the browser work server-side. Put the route in app/api/og/[slug]/route.ts and force the Node runtime.

import { chromium } from 'playwright';
import { NextRequest } from 'next/server';

export const runtime = 'nodejs';

const SITE_ORIGIN = process.env.SITE_ORIGIN ?? 'https://example.com';
const allowedSlugs = new Set(['pricing', 'docs', 'about']);

export async function GET(
  request: NextRequest,
  { params }: { params: { slug: string } }
) {
  const { slug } = params;
  if (!allowedSlugs.has(slug)) {
    return new Response('Unknown page', { status: 404 });
  }

  const version = request.nextUrl.searchParams.get('v') ?? '1';
  const target = `${SITE_ORIGIN}/${slug}?og_version=${encodeURIComponent(version)}`;
  const browser = await chromium.launch({ headless: true });

  try {
    const page = await browser.newPage({
      viewport: { width: 1200, height: 630 },
      deviceScaleFactor: 1,
      colorScheme: 'light',
      locale: 'en-US'
    });
    await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30_000 });
    await page.locator('[data-og-ready="true"]').waitFor({ state: 'visible', timeout: 10_000 });
    await page.evaluate(() => document.fonts.ready);
    await page.addStyleTag({ content: '* { animation: none !important; transition: none !important; }' });
    const png = await page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: 1200, height: 630 } });
    return new Response(png, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'public, max-age=300, s-maxage=86400, stale-while-revalidate=604800'
      }
    });
  } catch (error) {
    console.error('OG capture failed', { slug, error });
    return new Response('OG image unavailable', { status: 502 });
  } finally {
    await browser.close();
  }
}

Render data-og-ready="true" only after your page has loaded the title, fonts and images needed in the card. A known marker is more reliable than an arbitrary sleep. If you cannot add a marker, wait for a stable selector and then a short bounded delay.

3. Connect the route to App Router metadata

Metadata must contain an absolute, publicly reachable URL. A crawler should be able to request it without cookies, authentication or JavaScript execution.

import type { Metadata } from 'next';

export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  const image = `https://www.example.com/api/og/${params.slug}?v=3`;
  return {
    title: 'Pricing',
    openGraph: {
      type: 'website',
      url: `https://www.example.com/${params.slug}`,
      images: [{ url: image, width: 1200, height: 630, alt: 'Pricing page preview' }]
    },
    twitter: {
      card: 'summary_large_image',
      images: [image]
    }
  };
}

Layouts inherit metadata, so define shared values at the appropriate segment. Next.js also recognizes image files placed at the root of an App Router segment; dynamic images can use ImageResponse. See [Next.js metadata and OG images](https://nextjs.org/learn/dashboard-app/seo-optimizations).

4. Capture the right browser state

Viewport, full page and element

// Fixed social-card viewport
await page.setViewportSize({ width: 1200, height: 630 });
await page.screenshot({ path: 'card.png' });

// Entire document (usually too tall for an OG card)
await page.screenshot({ path: 'full.png', fullPage: true });

// A component only
await page.locator('[data-share-card]').screenshot({ path: 'element.png' });

For a live-page OG image, a fixed viewport and a deliberate crop usually produce a recognizable preview. Full-page capture is useful when the shared image is a document preview, but social platforms may scale very tall images down. Use a selected element when your page contains a stable share-card region.

Consent overlays and third-party widgets must be handled before the pixels are captured.
Consent overlays and third-party widgets must be handled before the pixels are captured.

Fonts, images and motion

  • Wait for document.fonts.ready and for critical images to report complete.
  • Disable animations, carousels and blinking cursors; otherwise two captures can differ.
  • Set locale, timezone and color scheme explicitly when content changes with them.
  • Use a consistent device scale factor. A scale of 1 makes byte size and dimensions predictable.

Consent banners, chat bubbles and newsletter modals can cover the page. Prefer a capture-only CSS class that hides them, or remove them in the page context after consent is handled. Keep that behavior scoped to the OG route so normal visitors still see required notices. Avoid loading analytics and advertising resources in the capture path when they are not part of the preview.

5. Make the endpoint reliable and cacheable

  1. Allowlist content. Resolve a slug or database ID to a trusted URL. If external targets are unavoidable, enforce an origin allowlist, block private IP ranges and reject redirects outside the allowlist.
  2. Prevent recursion. The captured page must not load metadata or assets that call the same screenshot endpoint.
  3. Bound every wait. Set navigation and readiness timeouts, return a 5xx on failure, and log the slug and stage.
  4. Reuse resources carefully. A long-lived browser can reduce launch overhead, but recycle it after crashes or memory growth. Close every page and context.
  5. Version output. Put a content version in the image URL (?v=3 or a hash). Keep a CDN policy aligned with editorial updates. Social networks may retain their own copy.
  6. Expose the route to crawlers. Vercel recommends allowing OG image API routes in robots.txt, for example Allow: /api/og/*. Confirm your own robots rules and hosting firewall permit the route.

Generated-image CDNs can cache responses, but do not assume the same behavior for a custom browser route. Measure cold and warm latency in your deployment, and set a stale-while-revalidate window so a temporary browser failure does not immediately erase previews.

6. Troubleshooting checklist

Symptom Likely cause Fix
Social debugger shows no image Relative URL, authentication, robots block or non-200 response Use an absolute HTTPS URL, curl it without cookies, allow the route in robots.txt, and return image bytes with 200.
Blank or half-rendered card Capture occurs before app data, fonts or images are ready Wait for a readiness selector, document.fonts.ready and critical image completion.
Timeout on deployment Browser binary missing, cold start too slow or third-party request hangs Install the runtime browser, use a supported Node environment, block unnecessary resources and set explicit navigation timeouts.
Unexpected cookie banner Consent state differs in a fresh context Use a capture-only route, set the required consent cookie, or hide the known banner after verifying legal requirements.
Different image on every request Animations, random data, locale or timezone variance Freeze motion, seed data, and set locale/timezone explicitly.
SSRF finding Public route accepts arbitrary destination Replace URL input with an allowlisted slug; validate redirects and network ranges if external capture is required.
Image is cropped badly Viewport and content aspect ratios disagree Design the target at 1200×630, use a fixed clip, or capture a dedicated element.

7. Validate before publishing

  1. Deploy the route and run curl -I https://www.example.com/api/og/pricing?v=3. Check 200, Content-Type: image/png and cache headers.
  2. Fetch the bytes without a session cookie and inspect the dimensions.
  3. View the rendered HTML and confirm an absolute og:image plus Twitter card metadata where needed.
  4. Test a slug that does not exist, a slow page and a browser failure; each should return a bounded, useful error.
  5. Use each target platform’s preview debugger after deployment. Remember that platforms cache images independently; change the version key when replacing an image.

Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. One GET request captures a URL as PNG, JPEG, WebP or PDF. Cookie banners are accepted and 60+ known consent platforms, newsletter popups and chat widgets are removed before the shot; each step can be turned off. Bot checks, blank pages, timeouts, failed loads and cache hits are not billed, and response headers report the page verdict and billing state. Its MCP tools (take_screenshot, get_page_info, capture_pdf) work with Claude, Cursor and other MCP clients.

See the complete option list in the ScreenshotNeo docs. Relevant controls include full-page or CSS-element capture, dark mode, device presets or custom viewports, retina scale, custom CSS/JavaScript, clicks, selector waits, network-idle waits, request blocking, headers, cookies, user agent, Authorization, timezone, geolocation, transparent backgrounds, resizing, TTL caching, signed links, async webhooks, bulk capture (100 URLs per call), usage API and OpenAPI specification.

cURL

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

Python

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)

Node.js

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 failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

For an OG route, proxy this call from a server handler, keep the access key secret, and return the received bytes with the matching image content type. Add your own allowlist and cache key. ScreenshotNeo’s plans include every feature: Free gives 1,000 shots/month with no card; Starter is $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000 and Business $249 for 1,000,000. Yearly billing gives two months free. Create a free ScreenshotNeo account to start with 1,000 screenshots a month and no card.

8. Cost and performance decisions

  • Browser self-hosting: each cold browser launch consumes CPU and memory; concurrency limits protect the rest of your app. Cache by content version and avoid recapturing identical pages.
  • External capture: an API removes browser packaging and can provide async jobs for bursts, but count requests and inspect verdict/billing headers so failures are visible.
  • Payload size: WebP or JPEG is smaller than PNG for photographic pages; PNG preserves text and transparency. Choose per page type.
  • Reliability: serve a last-known-good image when a fresh capture fails, and alert on sustained 5xx responses. Keep the metadata URL stable while versioning its content.

FAQ

Can I use a client component to generate the image?

No. Social crawlers request the image URL directly. Capture in a server runtime or use a screenshot service.

Is 1200×630 mandatory for Playwright?

No. It is Vercel’s recommendation for OG images. Playwright accepts your chosen viewport and clip; 1200×630 is a practical default for broad platform compatibility.

Should I use full-page screenshots?

Only when the shared image is intentionally a long document. For a social preview, a fixed viewport or dedicated element keeps text legible.

How do I refresh an image that a platform cached?

Change a content version in the metadata URL, deploy it, and request the new URL through the platform’s debugger if available.

Can an OG route screenshot any URL?

Technically yes, but a public arbitrary-URL route creates SSRF and abuse risk. Resolve known IDs to trusted pages or enforce a strict origin and network allowlist.