Blog 4 min read

Website Screenshots in Next.js Route Handlers (Without Chromium on Vercel)

Capture real browser screenshots from a Next.js App Router route handler on Vercel: why @vercel/og and Satori are not enough, the 50 MB function limit, and a fetch-only implementation with caching and error handling.

You want a route in your Next.js app that takes a URL and returns a screenshot of that page. The obvious tool is Puppeteer. On Vercel, the obvious tool does not fit.

Why the usual options fall short

Puppeteer or Playwright in the function. A full Chromium binary is far larger than a serverless function may be. The workaround, @sparticuz/chromium with puppeteer-core, ships a compressed build that unpacks at cold start. It works until it does not: bundle size limits, multi-second cold starts, and the recurring breakage tracked in that package's issue threads every time Next.js or Chromium moves. If you have ever pinned three package versions to keep one route alive, you know the pattern.

@vercel/og. It is the right tool for a different job. Satori converts JSX to SVG with a CSS subset, no JavaScript, no external stylesheets. It draws cards you design. It cannot load https://example.com and show you what it looks like.

A hosted screenshot API. The browser runs somewhere else; your route handler makes one HTTP request and streams the result. Nothing to bundle, nothing to pin.

Screenshots as an API call

Send a GET request, get a PNG back. Ads and cookie banners blocked by default.

200 free renders a month. Paid plans start at $9.

The route handler

App Router, app/api/screenshot/route.ts. Works on the Node.js runtime and on Edge.

import { NextRequest, NextResponse } from 'next/server';

export const runtime = 'nodejs'; // 'edge' works too: this file is fetch-only

const API = 'https://app.snap-render.com/v1/screenshot';

export async function GET(req: NextRequest) {
  const url = req.nextUrl.searchParams.get('url');
  if (!url || !/^https?:\/\//.test(url)) {
    return NextResponse.json({ error: 'url query parameter required' }, { status: 400 });
  }

  const params = new URLSearchParams({
    url,
    format: 'png',
    full_page: 'true',
    block_ads: 'true',
    block_cookie_banners: 'true',
    cache_ttl: '86400',
  });

  const upstream = await fetch(`${API}?${params}`, {
    headers: { 'X-API-Key': process.env.SNAPRENDER_API_KEY! },
  });

  if (!upstream.ok) {
    const detail = await upstream.text();
    return NextResponse.json({ error: 'capture failed', detail }, { status: upstream.status === 429 ? 429 : 502 });
  }

  return new NextResponse(upstream.body, {
    status: 200,
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=604800',
    },
  });
}

Set SNAPRENDER_API_KEY in the Vercel project settings (Environment Variables). The key never reaches the browser; clients call /api/screenshot?url=... on your own domain.

What the parameters do

  • full_page=true captures the whole scrollable page, lazy-loaded images included. Drop it for a viewport-only shot.
  • block_ads and block_cookie_banners are on by default; they are spelled out here so the intent is visible in code review.
  • cache_ttl=86400 keeps the rendered image for a day on the API side. Repeat requests for the same URL and options return the cached image and do not count against your quota, which matters when a page like a link preview renders the same target many times.

The Cache-Control header on your own response lets Vercel's CDN serve the image without calling your function at all.

Storing instead of streaming

If you need the file later (a gallery, a report), write it to storage from the same handler. With Vercel Blob:

import { put } from '@vercel/blob';

const buffer = Buffer.from(await upstream.arrayBuffer());
const blob = await put(`screenshots/${Date.now()}.png`, buffer, { access: 'public', contentType: 'image/png' });
return NextResponse.json({ url: blob.url });

Handling limits and failures

Three responses are worth handling explicitly:

  • 429: the monthly quota or the per-minute rate is exhausted. Return 429 to your caller, or fall back to a placeholder image; do not retry in a tight loop.
  • 4xx for the target URL (unreachable host, blocked private address): the target is the problem, not your code. Surface the message.
  • Timeouts on very slow pages: pass a delay (milliseconds after load) when a page needs extra settle time, and set a timeout on your own fetch with AbortSignal.timeout(60_000) so the function never hangs to its limit.

PDF and other formats

Change format to jpeg, webp or pdf. PDF output is a rasterized image of the page, not a text layer, which is the right thing for "what did this page look like" and the wrong thing for invoices you want to be searchable.

When to run your own browser anyway

If you need to click through a login, fill forms, or run custom scripts before capturing, a browser automation platform is the better fit. For "show me this URL as an image", the fetch-only route above is the whole implementation.

Related: screenshot API for serverless runtimes, Node.js quickstart, link preview API guide.

Frequently asked questions

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

Only with a trimmed Chromium build such as @sparticuz/chromium, and it is fragile: the function must stay under the 250 MB uncompressed limit (50 MB on some tiers), cold starts take seconds, and every Next.js or Chromium upgrade can break the bundle. Most teams move the browser out of the function.

Why not use @vercel/og for screenshots?

@vercel/og renders JSX through Satori, which supports a subset of CSS and no JavaScript. It is excellent for cards you design yourself and cannot capture an existing web page.

Does this work on the Edge runtime?

Yes. The example only uses fetch and standard Web APIs, so it runs on both the Node.js and Edge runtimes in Next.js.

How do I keep the API key out of the client?

The route handler runs on the server; read the key from process.env.SNAPRENDER_API_KEY, which you set in the Vercel project settings. The browser only ever sees your own /api route.

Screenshots as an API call

200 free renders a month. Paid plans start at $9.

Sign up free