# 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 2026-11-08 · https://shareglint.com/en/guides/website-screenshots-nextjs

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](https://vercel.com/docs/functions/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`](https://github.com/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](https://shareglint.com/en/guides/run-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.

```typescript
// 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.

```typescript
// 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}`;
}
```

```tsx
// 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](https://nextjs.org/docs/app/getting-started/server-and-client-components)). The signing rule (sorted, RFC 3986-encoded query, HMAC-SHA256, 32 hex characters) is the same as in our [JavaScript guide](https://shareglint.com/en/screenshot-api/javascript), 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](https://shareglint.com/en/og-image-generator/nextjs)) |

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](https://shareglint.com/en/guides/nextjs-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](https://shareglint.com/en/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.
