Next.js Screenshot API Integration

Add screenshots to Next.js with one fetch call. Route Handlers, Server Components, ISR caching, OG image generation. No Puppeteer or Chromium needed.

Vitalii Holben Updated

The ScreenshotRun Next.js screenshot API turns any URL into an image with one HTTP call. In a Next.js project, that call fits naturally into Route Handlers, Server Components, Server Actions, even Middleware — anywhere you can run server-side code. No Chromium binary to bundle, no Vercel function size limits to worry about.

This page covers the patterns that matter for Next.js specifically. For raw Node.js basics (fetch, axios, error handling), see the Node.js integration guide. Everything here is App Router first, TypeScript throughout, with Pages Router equivalents where needed.

Screenshot from a Route Handler in 60 seconds

Create a file at app/api/screenshot/route.ts. This keeps your API key server-side and gives your frontend a local endpoint to call:

// app/api/screenshot/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const url = request.nextUrl.searchParams.get('url');
  if (!url) {
    return NextResponse.json({ error: 'Missing url' }, { status: 400 });
  }

  const params = new URLSearchParams({
    url,
    format: 'webp',
    width: '1280',
    response_type: 'image',
  });

  const res = await fetch(
    `https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
    { headers: { Authorization: `Bearer ${process.env.SCREENSHOTRUN_KEY}` } },
  );

  if (!res.ok) {
    return NextResponse.json({ error: 'Capture failed' }, { status: res.status });
  }

  const buffer = await res.arrayBuffer();

  return new NextResponse(buffer, {
    headers: {
      'Content-Type': 'image/webp',
      'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600',
    },
  });
}

Call it from any client component with /api/screenshot?url=https://github.com. The response comes back as an image you can drop into an <img> tag or pass to next/image. Your API key never leaves the server.

Why not just run Puppeteer on Vercel?

Puppeteer bundles Chromium at around 400 MB. Vercel's serverless function size limit (250 MB uncompressed) technically fits a stripped-down @sparticuz/chromium-min build, but you're right at the edge with no room for the rest of your application. And that's before cold starts enter the picture. Loading a headless browser from scratch in a serverless function takes 3 to 8 seconds, sometimes longer. Your users are waiting that entire time. This is the main reason developers look for a Puppeteer Next.js alternative.

Beyond size and speed, there are reliability issues unique to serverless environments. Vercel functions run on a read-only filesystem, so Puppeteer can't download its browser binary at runtime (a common 500 error that confuses developers the first time they hit it). Concurrent requests spawn parallel Chrome instances that fight over memory. The libnspr4.so missing library error has its own dedicated troubleshooting threads on GitHub.

An external screenshot API sidesteps all of this. Your Route Handler makes a fetch call — the same kind of lightweight HTTP request that Edge Runtime handles without issues. No binary dependencies, no filesystem writes, no memory spikes, no cold starts eating into your response time. The browser runs on ScreenshotRun's infrastructure; your function stays under 1 MB.

Next.js Server Component screenshot pattern

React Server Components can call the screenshot API directly during rendering. This is the cleanest Next.js server component screenshot approach. No Route Handler, no client-side JavaScript:

// app/site-preview/[url]/page.tsx
import Image from 'next/image';

async function getScreenshot(targetUrl: string): Promise<string> {
  const params = new URLSearchParams({
    url: targetUrl,
    format: 'webp',
    width: '1280',
    quality: '80',
  });

  const res = await fetch(
    `https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
    {
      headers: { Authorization: `Bearer ${process.env.SCREENSHOTRUN_KEY}` },
      next: { revalidate: 86400 },
    },
  );

  if (!res.ok) throw new Error(`Capture failed: ${res.status}`);

  const buffer = Buffer.from(await res.arrayBuffer());
  const base64 = buffer.toString('base64');
  return `data:image/webp;base64,${base64}`;
}

export default async function SitePreview({
  params,
}: {
  params: Promise<{ url: string }>;
}) {
  const { url } = await params;
  const decoded = decodeURIComponent(url);
  const src = await getScreenshot(decoded);

  return (
    <div>
      <h1>Preview: {decoded}</h1>
      <Image src={src} alt={`Screenshot of ${decoded}`} width={1280} height={720} />
    </div>
  );
}

Setting next: { revalidate: 86400 } tells Next.js to cache this fetch for 24 hours. After that, the next visitor triggers a background re-fetch while still seeing the cached version (stale-while-revalidate). Your screenshot API gets called once per day per URL instead of once per visitor.

Could you skip the base64 step and return the image URL directly? Yes, if the API returns a hosted URL (omit response_type: 'image'). But base64 inlining removes the external dependency at render time. The page works even if the API is temporarily unreachable after the initial capture.

OG images that @vercel/og can't render

Next.js ships with @vercel/og for generating Open Graph images. It uses Satori under the hood, which renders a limited subset of CSS: flexbox only, no grid, no pseudo-elements, no CSS variables, no calc(). Satori silently ignores unsupported properties instead of throwing errors, so your OG card breaks without any warning in the logs.

For simple text-on-gradient cards, @vercel/og works fine. But what happens when you need a dashboard with live charts? Or a product page with a complex grid layout? Satori can't render those, and it won't tell you why — the output just looks wrong.

Instead, build a hidden page that renders your OG card with real CSS, then screenshot it at 1200x630:

// app/api/og/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const slug = request.nextUrl.searchParams.get('slug') || '';

  // Point at your own OG card page with full CSS support
  const cardUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/og-card/${slug}`;

  const params = new URLSearchParams({
    url: cardUrl,
    format: 'png',
    width: '1200',
    height: '630',
    delay: '500',
    response_type: 'image',
  });

  const res = await fetch(
    `https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
    {
      headers: { Authorization: `Bearer ${process.env.SCREENSHOTRUN_KEY}` },
      next: { revalidate: 604800 },
    },
  );

  if (!res.ok) {
    return new NextResponse('OG generation failed', { status: 500 });
  }

  return new NextResponse(await res.arrayBuffer(), {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=604800, immutable',
    },
  });
}

Then in your page's metadata:

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  return {
    openGraph: {
      images: [`/api/og?slug=${slug}`],
    },
  };
}

Your OG card page (/og-card/[slug]) is a regular Next.js page with full CSS support: grid layouts, animations, custom fonts, emoji, whatever your design needs. The Next.js screenshot API renders it in a real browser, so the output matches exactly what you see in dev tools. Cache it for a week with revalidate: 604800 and the API gets called only when content changes. This is the most practical Next.js OG image approach when @vercel/og hits its limits.

Display screenshots with next/image

If the ScreenshotRun API returns a hosted URL (the default when you omit response_type), you can pass that URL to next/image for automatic optimization. First, add the domain to your config:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'api.screenshotrun.com',
        pathname: '/**',
      },
    ],
  },
};

export default nextConfig;

Then use it in any component:

<Image
  src={screenshotUrl}
  alt="Website screenshot"
  width={1280}
  height={720}
  quality={80}
  placeholder="blur"
  blurDataURL={thumbnailBase64}
/>

Next.js automatically converts the image to WebP (or AVIF on supported browsers), generates responsive srcset attributes for different viewport widths, lazy-loads below-fold images, applies blur placeholders during load. You get all of that without touching the screenshot API parameters. One practical tip: request the screenshot at a higher resolution (set retina=true or width=1920) and let next/image downscale for each breakpoint. The result looks sharp on retina displays without serving oversized files to mobile.

Cache screenshots with ISR

Incremental Static Regeneration is where Next.js gives you something no plain Node.js server can match. Generate a screenshot at build time or on first request, serve it from the edge cache, revalidate on a schedule. Visitors always get a fast response:

// app/preview/[domain]/page.tsx
interface ScreenshotData {
  url: string;
  capturedAt: string;
}

async function capturePreview(domain: string): Promise<ScreenshotData> {
  const params = new URLSearchParams({
    url: `https://${domain}`,
    format: 'webp',
    width: '1280',
    full_page: 'false',
    block_cookies: 'true',
  });

  const res = await fetch(
    `https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
    {
      headers: { Authorization: `Bearer ${process.env.SCREENSHOTRUN_KEY}` },
      next: { revalidate: 3600 },
    },
  );

  const data = await res.json();
  return { url: data.screenshot_url, capturedAt: new Date().toISOString() };
}

export default async function PreviewPage({
  params,
}: {
  params: Promise<{ domain: string }>;
}) {
  const { domain } = await params;
  const screenshot = await capturePreview(domain);

  return (
    <div>
      <h1>{domain}</h1>
      <img src={screenshot.url} alt={`${domain} screenshot`} />
      <p>Captured: {screenshot.capturedAt}</p>
    </div>
  );
}

export async function generateStaticParams() {
  // Pre-generate screenshots for your most popular domains at build time
  return [
    { domain: 'github.com' },
    { domain: 'vercel.com' },
    { domain: 'stripe.com' },
  ];
}

With revalidate: 3600, each domain's screenshot refreshes once per hour. Between refreshes, every visitor gets the cached version instantly — zero API calls, zero latency. For a directory site or link aggregator showing Next.js website thumbnails, this pattern keeps your API usage predictable regardless of traffic spikes. See serverless screenshot workflows for more async patterns.

Server Actions for form-driven screenshots

When users submit a URL through a form, Server Actions handle the capture without a separate API route:

// app/actions/screenshot.ts
'use server';

export async function captureUrl(formData: FormData) {
  const url = formData.get('url') as string;
  if (!url) return { error: 'URL is required' };

  const params = new URLSearchParams({
    url,
    format: 'webp',
    width: '1280',
    block_cookies: 'true',
    block_ads: 'true',
    response_type: 'image',
  });

  const res = await fetch(
    `https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
    { headers: { Authorization: `Bearer ${process.env.SCREENSHOTRUN_KEY}` } },
  );

  if (!res.ok) return { error: `Capture failed: ${res.status}` };

  const buffer = Buffer.from(await res.arrayBuffer());
  const base64 = buffer.toString('base64');

  return { image: `data:image/webp;base64,${base64}` };
}

Call it from a client component with useActionState for loading states and progressive enhancement. The form works even with JavaScript disabled because the Server Action runs entirely on the server. Next.js handles the UI update when the result comes back. Pair this with ad blocking and cookie banner removal for clean captures from user-submitted URLs.

Edge Runtime compatibility

Because the screenshot API is just an HTTP fetch, your Route Handler works on Edge Runtime too. Add the runtime export at the top of the file:

export const runtime = 'edge';

export async function GET(request: Request) {
  // Same code as the Route Handler example above
  // Works on Edge because there's no Node.js-specific code
}

Edge functions start in milliseconds instead of hundreds of milliseconds. They run closer to your users geographically. The tradeoff: Edge Runtime doesn't support Node.js APIs like fs or Buffer. For a screenshot API call, you don't need either. If your response needs Buffer (for base64 encoding), stick with the default Node.js runtime.

Pages Router: the API route pattern

Still on Pages Router? The equivalent pattern uses pages/api instead of Route Handlers:

// pages/api/screenshot.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  const { url } = req.query;
  if (!url || typeof url !== 'string') {
    return res.status(400).json({ error: 'Missing url' });
  }

  const params = new URLSearchParams({
    url,
    format: 'png',
    width: '1280',
    response_type: 'image',
  });

  const response = await fetch(
    `https://api.screenshotrun.com/v1/screenshots/capture?${params}`,
    { headers: { Authorization: `Bearer ${process.env.SCREENSHOTRUN_KEY}` } },
  );

  if (!response.ok) {
    return res.status(response.status).json({ error: 'Capture failed' });
  }

  const buffer = Buffer.from(await response.arrayBuffer());

  res.setHeader('Content-Type', 'image/png');
  res.setHeader('Cache-Control', 'public, max-age=86400');
  res.send(buffer);
}

Both versions do the same thing: read the URL, call the API, pipe the image back. The only difference is Next.js's response API: res.send() instead of new NextResponse(). If you're migrating from Pages Router to App Router, the screenshot integration is one of the simpler things to move over.

Next.js screenshot API parameter reference

These ScreenshotRun parameters are particularly relevant for Next.js projects:

ParameterNext.js relevance
formatUse webp for Route Handlers returning images. Use png for OG images (social platforms prefer PNG). See format options
full_pageFull-page screenshots of long pages can exceed Vercel's response body size limit. Use Node.js runtime for these
width / heightSet 1200x630 for OG images. Set 1280x720 or 1920x1080 for general captures
retinaProduces 2x resolution. Pair with next/image for sharp rendering on high-DPI screens
block_cookiesRemoves consent banners. Essential for clean OG images and link preview thumbnails
block_adsStrips ads before capture. Use with block_chats for pristine screenshots
delayWait for client-side rendering to finish. Useful when screenshotting SPA pages with hydration delays
dark_modeCaptures the dark theme variant. Matches if your Next.js app respects prefers-color-scheme
cache_ttlServer-side API cache. Combine with Next.js revalidate for two-layer caching
webhook_urlAsync capture with webhook delivery. Point at a Route Handler to receive results without holding a connection open

Start capturing screenshots from Next.js

Pick the pattern that fits your project. Route Handlers for API proxies, Server Components for rendered previews, ISR for cached website thumbnails, Server Actions for user-submitted URLs. The free tier gives you 200 captures per month, enough to build and test the Next.js screenshot API integration before going to production. Grab your API key from the dashboard and drop it into your .env.local.

FAQ#

Call the ScreenshotRun API from a Route Handler or Server Component using fetch(). Pass the target URL as a query parameter and receive the image as binary data or a hosted URL. No Chromium binary, no npm packages beyond what Next.js ships with. The API handles browser rendering on its own infrastructure.

Yes. A screenshot API call is a regular HTTP fetch, so it works in both Node.js Runtime and Edge Runtime on Vercel. Unlike Puppeteer, which needs a 400 MB Chromium binary and often exceeds Vercel function size limits, an API call adds zero to your bundle size and starts with no cold delay.

Build an internal page at /og-card/[slug] with full CSS support (grid, custom fonts, charts, anything a browser can render). Then create a Route Handler that screenshots that page at 1200x630 via the API and returns the PNG with a long Cache-Control header. Reference the Route Handler URL in your generateMetadata() function. This bypasses Satori flexbox-only limitations entirely.

Yes. In App Router, use a Route Handler (app/api/screenshot/route.ts) or call the API directly from a Server Component or Server Action. In Pages Router, use an API route (pages/api/screenshot.ts) with the same fetch logic. The API call is identical in both cases — only the Next.js response wrapper differs.

Two layers work well together. On the Next.js side, use the revalidate option in fetch() or export a revalidate constant from your page to enable ISR caching. On the API side, set the cache_ttl parameter so the API returns a cached screenshot instead of re-capturing. Combined, visitors get instant responses from the edge cache while the API is called only when both caches expire.

Add api.screenshotrun.com to the remotePatterns array in next.config.ts. Then pass the screenshot URL as the src prop to the Image component. Next.js automatically converts to WebP or AVIF, generates responsive srcset attributes, lazy-loads below-fold images, and serves through its built-in image CDN. Request retina resolution from the API and let next/image downscale per breakpoint for sharp results on all devices.

Written by

Vitalii Holben

Co-founder and CEO of ScreenshotRun. Full-stack developer building screenshot API tools that help developers capture, automate, and monitor web pages at scale. Based in Europe, shipping code since 201...

Screenshot API in a few clicks

Free plan includes 200 screenshots every month. No credit card required.

Sign up → copy your key → make your first call