Puppeteer does not fit inside a Vercel Function without a stripped-down Chromium build. One fetch call to a hosted renderer does the same job in a 2 KB route handler.
200 renders a month, no card.
Vercel Functions run on AWS Lambda, and Lambda caps a deployment at 250 MB uncompressed. Stock Puppeteer downloads a Chromium build of roughly 170 MB and expects a full set of shared libraries, so the usual workaround is @sparticuz/chromium: a Brotli-compressed Chromium that unpacks into /tmp on first invocation. It works until it does not. Every Chromium release needs a matching puppeteer-core version, the unpack step adds seconds to each cold start, and the first sign of a mismatch is a "Could not find Chrome" error in production at 2 a.m.
The Vercel community thread on this problem has run for years and the answers rotate between pinning versions, moving to the Edge runtime (which cannot run Chromium at all), and giving up on self-hosting. The 800-second Pro maximum duration is generous, but memory is capped at 2 GB on Hobby, and a browser that renders a heavy page plus the Node process that drives it can exceed that.
A hosted screenshot API removes the browser from your deployment entirely. The route handler below is plain fetch: no native dependencies, no bundle size to think about, and it runs unchanged on the Node.js runtime, the Edge runtime, and locally with next dev.
References: Vercel Functions limits (250 MB uncompressed), Vercel community thread on Puppeteer bundle size, @sparticuz/chromium issue tracker.
Call GET https://app.snap-render.com/v1/screenshot with the target in the url parameter and your key in the X-API-Key header. The response body is the finished image (or PDF when format=pdf). Ads and cookie banners are blocked by default.
# Vercel dashboard: Settings > Environment Variables, or with the CLI
vercel env add SNAPRENDER_API_KEY production
# paste sk_live_... when prompted, then redeploy
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs'; // 'edge' works too: this file is fetch-only
export async function GET(req: NextRequest) {
const target = req.nextUrl.searchParams.get('url');
if (!target) {
return NextResponse.json({ error: 'url query parameter is required' }, { status: 400 });
}
const params = new URLSearchParams({
url: target,
format: 'png',
width: '1280',
height: '800',
full_page: 'true',
cache: 'true',
cache_ttl: '86400',
});
const upstream = await fetch(`https://app.snap-render.com/v1/screenshot?${params}`, {
headers: { 'X-API-Key': process.env.SNAPRENDER_API_KEY! },
});
if (upstream.status === 429) {
const body = await upstream.json();
// RATE_LIMITED (per-minute burst) or QUOTA_EXCEEDED (monthly plan limit)
return NextResponse.json({ error: body.error.code }, { status: 429 });
}
if (!upstream.ok) {
const body = await upstream.json();
return NextResponse.json({ error: body.error.message }, { status: upstream.status });
}
const image = await upstream.arrayBuffer();
return new NextResponse(image, {
status: 200,
headers: {
'Content-Type': 'image/png',
// Let Vercel's CDN serve repeat hits so the API is only called once a day per URL
'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=3600',
},
});
}
put() needs a Blob store connected to the project (BLOB_READ_WRITE_TOKEN is injected automatically). The returned blob.url is a permanent CDN address you can save on a database row.
import { put } from '@vercel/blob';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const { url } = await req.json();
const upstream = await fetch(
`https://app.snap-render.com/v1/screenshot?url=${encodeURIComponent(url)}&format=jpeg&quality=85`,
{ headers: { 'X-API-Key': process.env.SNAPRENDER_API_KEY! } },
);
if (!upstream.ok) {
return NextResponse.json({ error: 'capture failed' }, { status: 502 });
}
const blob = await put(`screenshots/${Date.now()}.jpg`, await upstream.arrayBuffer(), {
access: 'public',
contentType: 'image/jpeg',
addRandomSuffix: true,
});
return NextResponse.json({ url: blob.url });
}
Caching is off by default. Pass cache=true and a cache_ttl in seconds (default 86400, maximum depends on plan). A repeat request for the same URL and parameters inside that window is served from the cache and does not count against your monthly quota. The X-Cache response header reports HIT or MISS, and X-Remaining-Credits shows what is left this month.
Every error body is { "error": { "code", "message", "status" } }.
Full parameter list, POST with raw HTML, signed URLs, and batch jobs are in the API reference. The OpenAPI spec is at /openapi.json.
Yes, with @sparticuz/chromium and puppeteer-core on the Node.js runtime, provided the unpacked bundle stays under 250 MB and the two package versions match. It cannot run on the Edge runtime. Most teams that start this way end up spending more time on version pinning and cold starts than on their feature, which is why a fetch-only integration is the usual endpoint.
Yes. The route handler only uses fetch, URLSearchParams, and Response, all of which exist on the Edge runtime. Change the runtime export to edge and nothing else.
Set cache=true and a cache_ttl in seconds on the request. A repeat request for the same URL and parameters inside that window returns the stored image and does not count against your monthly quota. Add a Cache-Control header with s-maxage on your own response so Vercel's CDN absorbs repeat traffic before it reaches the API.
Two things share that status. RATE_LIMITED means you exceeded the per-minute burst limit for your plan; back off and retry. QUOTA_EXCEEDED means the monthly render allowance is used up; the JSON body includes the plan name and the upgrade path.
Yes. The free plan includes 200 renders a month with every feature enabled, and no card is required. Paid plans start at $9 a month for 2,000 renders.
Related guide: Screenshots in Next.js route handlers (when Satori is not enough). Language quickstarts: Node.js, Python, cURL. Cost comparison: Puppeteer on Lambda vs an API.
Or skip the setup entirely
Everything in this guide is one GET request with SnapRender. No browser to babysit, no timeouts to tune.
200 screenshots a month free. First render in under a minute.
Get a free API key, 200 renders a month, no card.