Menu
Sign up

Website screenshots in Next.js: why Chromium is hard on Vercel, and two routes that work

Website screenshots from a Next.js app: why Chromium hits Vercel's 250 MB limit, a route handler calling an API, and a signed URL in a Server Component.

Published November 8, 2026 · 5 min read

A Next.js app that needs pictures of web pages — link previews in a dashboard, thumbnails in a directory, a "your site" card in onboarding — runs into the same question: where does the browser run? On a laptop or a server, Puppeteer is one npm install. In a serverless function on Vercel, Chromium is too big for the default limits. This page shows the limits, the two routes that avoid them, and the code we ran.

How we tested. On 2026-11-08 we ran a Next.js 16.4.5 app (React 19.3.1) with next dev against our screenshot API on our development stack: a route handler and a Server Component, both below. Vercel's limits are quoted from its documentation as read that day; we did not deploy to Vercel.

Why Chromium and Vercel functions do not mix by default

From Vercel's function limitations: "For Vercel Functions, the maximum uncompressed size is 250 MB including layers". The same page lists "Puppeteer with its bundled Chromium" among the heavy dependencies that break it, with the build error Serverless Function has exceeded the unzipped maximum size of 250 MB. Other limits from the page: memory up to 2 GB on Hobby and 4 GB on Pro and Enterprise, and a default duration of 300 s.

The numbers behind that:

Piece Size we measured or read
Browsers downloaded by puppeteer 25.7.0 (Chrome 152) 652 MB in its cache directory
puppeteer-core 25.16.0 (no browser) 6 MB unpacked
@sparticuz/chromium 153.0.0, "Chromium Binary for Serverless Platforms" 70 MB unpacked

So the full package cannot fit; puppeteer-core plus a compressed serverless Chromium can, which is the route most "Puppeteer on Vercel" guides take. It works, with costs: a cold start unpacks Chromium into /tmp, fonts beyond the basics are your job (emoji and CJK; see Puppeteer in Docker for what a minimal image lacks), each capture holds a function's memory for as long as the page takes to load, and the Chromium package and puppeteer-core versions have to match. Vercel's large functions beta (up to 5 GB, per the same page) removes the size problem but not the others.

Route 1: a route handler that calls a screenshot API

No browser in the function: the handler asks the API for a capture and redirects to the stored file.

// app/api/screenshot/route.ts — a screenshot endpoint without a browser in the function.
import { NextRequest } from 'next/server';

const ALLOWED = new Set(['example.com', 'www.python.org', 'nextjs.org']); // never screenshot arbitrary URLs for strangers

export async function GET(request: NextRequest) {
  const target = request.nextUrl.searchParams.get('url') ?? '';
  let host = '';
  try { host = new URL(target).hostname; } catch {}
  if (!ALLOWED.has(host)) return new Response('URL not allowed', { status: 400 });

  const res = await fetch(`${process.env.SHOT_API}/v1/screenshots?wait=true`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.SHOT_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ url: target, format: 'webp', block_cookie_banners: true }),
  });
  const shot = await res.json();
  if (!res.ok || shot.status !== 'completed') {
    return Response.json({ error: shot.error ?? shot.message ?? 'Screenshot failed' }, { status: 502 });
  }
  // Send the browser to the stored file; let the CDN keep the answer for a day.
  return new Response(null, { status: 302, headers: { Location: shot.url, 'Cache-Control': 'public, s-maxage=86400' } });
}

In our run, /api/screenshot?url=https://www.python.org/ answered 302 with Cache-Control: public, s-maxage=86400 and the image's address; a second call, served from the screenshot cache, took 161 ms. A request for http://169.254.169.254/ got 400 URL not allowed before anything was fetched. The allow-list matters: without it the route is an open proxy for anyone to screenshot anything on your quota.

SHOT_API_KEY stays in the server environment. Set it in Vercel's environment variables without a NEXT_PUBLIC_ prefix, which would put it in the browser bundle.

Route 2: a signed URL in a Server Component

For images on your own pages, you do not even need a route. Sign the screenshot URL on the server; the <img> loads it directly from the screenshot service.

// lib/signed-screenshot.ts — server-only: builds a signed /s/ URL for an <img>.
import 'server-only';
import { createHmac } from 'node:crypto';

const enc = (v: string) => encodeURIComponent(v).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());

export function signedScreenshotUrl(pageUrl: string, options: Record<string, string> = {}, ext = 'webp') {
  const params: Record<string, string> = { url: pageUrl, ...options };
  const path = `/s/${process.env.SHOT_ACCOUNT_ID}.${ext}`;
  const query = Object.keys(params).sort().map((k) => `${enc(k)}=${enc(params[k])}`).join('&');
  const s = createHmac('sha256', process.env.SHOT_SIGNING_SECRET!).update(`${path}?${query}`).digest('hex').slice(0, 32);
  return `${process.env.SHOT_API}${path}?${query}&s=${s}`;
}
// app/page.tsx — a Server Component: the URL is signed on the server, the browser only sees the result.
import { signedScreenshotUrl } from '../lib/signed-screenshot';

export default function Page() {
  const src = signedScreenshotUrl('https://nextjs.org/', { block_cookie_banners: '1' });
  return (
    <main>
      <h1>Sites we like</h1>
      <img src={src} width={640} height={400} alt="nextjs.org home page" loading="lazy" />
    </main>
  );
}

The rendered HTML held an <img> with the signed URL. Its first load answered 302 after 6.5 s — the capture — and the second in 0.1 s, a redirect to the stored file. import 'server-only' makes the build fail if a Client Component ever imports the signer, so the secret cannot end up in the browser; Next.js recommends the package "to prevent accidental usage in Client Components" (Next.js docs). The signing rule (sorted, RFC 3986-encoded query, HMAC-SHA256, 32 hex characters) is the same as in our JavaScript guide, where it was checked against the server's verifier.

With next/image, add the screenshot host to images.remotePatterns in next.config, or keep a plain <img> as above: the screenshot service already returns a sized, cached file.

Which route for which job

Job Route
Thumbnails on your own pages Signed URL in a Server Component
A URL your users type in (onboarding, a preview tool) Route handler with validation and a login
Screenshots behind your app's login, or of localhost A browser you run: puppeteer-core + serverless Chromium, or a container
OG images for your own pages, from your own data Not a screenshot: render a template (Next.js OG images)

The last row is a frequent mix-up: opengraph-image.tsx and next/og draw an image from JSX, they do not photograph a page. Our comparison of that path is in Next.js OG images without @vercel/og limits.

Cost and limits

A capture costs 1 render of the quota (2 for a full page), repeats with the same options within 24 hours are free, and the function on your side only waits for one HTTP request (pricing). The screenshot service reaches public pages only; for anything behind your login, run the browser yourself.

FAQ

Can I run Puppeteer in a Next.js API route on Vercel?

Only with a trimmed Chromium. Vercel's limit for a function is 250 MB uncompressed, and its docs name "Puppeteer with its bundled Chromium" as a typical cause of going over; the browsers that full Puppeteer 25.7 downloads took 652 MB on their own in our measurement. The usual workaround is puppeteer-core with @sparticuz/chromium (70 MB unpacked in version 153.0.0), or Vercel's large functions beta.

What is the simplest way to show a website screenshot in a Next.js page?

Sign a screenshot URL in a Server Component and put it in an <img>. The browser loads the image from the screenshot service; the first view captures the page (6.5 s in our test), later views are a redirect to the stored file (0.1 s). Your signing secret never leaves the server.

Should a Next.js route accept any URL to screenshot?

No. A public endpoint that screenshots whatever URL it is given is a free screenshot service for strangers and a way into internal addresses. Allow a fixed list of hosts, or require a signed-in user, and validate the URL before calling anything.