ScreenshotNeo

BlogHow-to

How to Take Website Screenshots with Puppeteer and Next.js

Build a Next.js screenshot endpoint with Puppeteer, choose the right capture and wait options, and avoid browser-install and deployment pitfalls.

By the ScreenshotNeo team4 October 20269 min read

To take website screenshots with Puppeteer and Next.js, create an App Router Route Handler that runs in the Node.js runtime, opens the target page in Puppeteer, captures it, and returns the image bytes in an HTTP response. The deployed server must include a compatible browser executable; a static-only Next.js export cannot run this request-time browser task.

This guide builds a basic endpoint, then covers URL safety, readiness, capture options, browser installation, deployment, performance, reliability, and common errors. Puppeteer’s documented screenshot workflow uses Page.screenshot(); an individual element can be captured with ElementHandle.screenshot(). See the Puppeteer screenshots guide and Next.js Route Handlers documentation.

1. Install Puppeteer and create the route

Install the full puppeteer package if your build can download and package its compatible browser. Create app/api/screenshot/route.ts:

// app/api/screenshot/route.ts
import puppeteer from 'puppeteer';

export const runtime = 'nodejs';

export async function GET(request: Request) {
  const target = new URL(request.url).searchParams.get('url');
  if (!target) return new Response('Missing url parameter', { status: 400 });

  let parsed: URL;
  try {
    parsed = new URL(target);
  } catch {
    return new Response('Invalid url parameter', { status: 400 });
  }
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    return new Response('Only http and https URLs are supported', { status: 400 });
  }

  let browser;
  try {
    browser = await puppeteer.launch();
    const page = await browser.newPage();
    page.setDefaultNavigationTimeout(30_000);
    await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
    await page.goto(parsed.toString(), { waitUntil: 'networkidle2', timeout: 30_000 });
    const image = await page.screenshot({ type: 'png', fullPage: true });
    return new Response(image, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'no-store',
        'X-Content-Type-Options': 'nosniff',
      },
    });
  } catch (error) {
    console.error('Screenshot capture failed', error);
    return new Response('Screenshot capture failed', { status: 502 });
  } finally {
    if (browser) await browser.close();
  }
}

Run the Next.js development server and request http://localhost:3000/api/screenshot?url=https%3A%2F%2Fexample.com. Save the response body as a PNG or open the URL in a browser. The example returns bytes directly and deliberately disables HTTP caching; adapt the response policy if you want caching.

Returning an image response is useful for a preview or download. If captures must persist or be shared independently of the request, add an explicit storage workflow; a Route Handler does not store the file by itself.

2. Choose when the page is ready

The example uses networkidle2 as a starting point, not a universal guarantee of visual readiness. A page may keep long-lived requests open, or it may finish network activity before client-rendered content, fonts, animations, or lazy images settle. Puppeteer documents the navigation and screenshot methods, but the correct readiness condition depends on the target application.

Readiness approach Use it when Tradeoff
domcontentloaded The initial document is enough or you will wait for a specific element next. Client-rendered content may not be present yet.
load The page’s load event is a reasonable signal. It does not mean every later visual change has stopped.
networkidle0 / networkidle2 Network activity settling is a useful signal for the page. Persistent requests can delay it; settled network activity does not guarantee stable pixels.
Application selector You control the site or know a reliable ready marker. The marker must represent actual capture readiness.

For a page with an explicit ready marker, navigate and wait for that selector:

await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForSelector('[data-screenshot-ready="true"]', { timeout: 10_000 });

A fixed delay is easy to add with page.waitForTimeout(ms) in Puppeteer versions that expose it, but it is usually less reliable than waiting for a meaningful selector: short delays race the page, while long delays waste request time.

3. Capture a viewport, whole page, or element

Use the viewport screenshot for a browser-sized preview. Set fullPage: true for the full document. To capture one component, query it and call its element screenshot method; Puppeteer scrolls the element into view when needed.

// Viewport image
const viewportImage = await page.screenshot({ type: 'png' });

// Whole document
const fullImage = await page.screenshot({ type: 'png', fullPage: true });

// One DOM element
const card = await page.$('.product-card');
if (!card) throw new Error('Product card was not found');
const elementImage = await card.screenshot({ type: 'png' });

Large full-page pages can create much bigger images and use more memory than a viewport or component capture. Prefer the smallest scope that serves the use case, and consider clipping a region when you need a fixed rectangle.

4. Set output and visual options

Puppeteer’s screenshot options cover output format and capture region. Screenshot output is a Uint8Array by default; base64 output is available when requested. Check the ScreenshotOptions API for the installed version’s exact types and defaults.

Option Effect Example
type Selects a supported output image type such as PNG, JPEG, or WebP, subject to the installed Puppeteer version. { type: 'jpeg', quality: 80 }
fullPage Captures the full page rather than only the visible viewport. { fullPage: true }
clip Captures a rectangle with x, y, width, and height. { clip: { x: 0, y: 0, width: 800, height: 500 } }
omitBackground Omits the default page background for transparency where the chosen format supports it. { type: 'png', omitBackground: true }
encoding Can request base64 instead of the default byte output. { encoding: 'base64' }
path Writes a screenshot to a file when the runtime filesystem permits it. { path: '/tmp/capture.png' }

JPEG output may be smaller for photographic pages but does not preserve transparency. PNG is appropriate for crisp interface graphics and transparent output. If returning base64 in JSON, account for its encoding overhead and response size; raw image bytes are simpler for a direct image endpoint.

5. Make the endpoint safe and predictable

A URL parameter makes the endpoint capable of causing your server to browse to a destination chosen by the caller. In production, restrict allowed schemes and hosts, reject loopback, private, link-local, and internal addresses, and validate DNS resolution and redirects so a permitted hostname cannot redirect to an internal resource. Apply authentication or rate limits if the endpoint is not meant to be public, and cap navigation time, output dimensions, and concurrent browser work.

Do not forward arbitrary caller-supplied headers or credentials to the target site. If you add custom headers, cookies, or authentication, define an explicit policy for which values are accepted and where they may be sent. Treat page content and captured images as untrusted input in downstream systems.

Use bounded timeouts for navigation and selector waits. Ensure the browser is closed in a finally path after success and failure. A browser process launched for every request is simple, but adds startup work; reusing a browser can reduce repeated launch overhead, but requires lifecycle management, isolation between pages, cleanup after crashes, and concurrency limits. Choose based on the host’s process model.

6. Install the browser and choose a deployment

The puppeteer package downloads a compatible Chrome for Testing and chrome-headless-shell during installation unless configuration or blocked install scripts prevent it. The installation guide lists approximate browser download sizes of 170 MB on macOS, 282 MB on Linux, and 280 MB on Windows; these are browser downloads, not complete deployment image sizes. If install scripts were blocked, run Puppeteer’s documented browser installation command during build or deployment.

puppeteer-core does not download Chrome. Use it when you manage the browser separately or connect to a remote browser, and provide the relevant executable path or connection setup yourself. This gives you control over browser lifecycle and packaging, while requiring you to keep the browser compatible with the Puppeteer version. See Puppeteer installation guidance.

Next.js supports Node.js server and Docker deployments with full framework features; static export has limited support. A request-time screenshot route needs a running server and a browser executable, so static-only export is not suitable. Hosting limits for function size, duration, memory, and writable filesystem vary by provider; check the current limits and browser support for the deployment you choose. The Next.js deployment guide describes deployment models. For self-hosting, Next.js recommends putting a reverse proxy in front of the server to handle concerns such as malformed requests, slow connections, payload limits, and rate limiting.

7. Troubleshooting

Symptom Likely cause Fix
Could not find Chrome or browser executable missing Install scripts were blocked, or the browser was not packaged. Run Puppeteer’s browser installation command in the build and verify the artifact includes the downloaded browser. With puppeteer-core, configure a managed executable or remote connection.
Launch fails only in production The host lacks a compatible executable or required runtime support. Use a Node.js server or Docker environment that can run the browser; confirm provider limits and launch requirements for the chosen deployment.
Navigation times out The site is slow, never reaches the chosen network-idle condition, or has a long-running request. Set an explicit timeout and choose a readiness condition suited to the target. Wait for an application marker when available.
Screenshot is blank or missing dynamic content Capture happened before client rendering or page-specific readiness. Wait for a meaningful selector or application-ready signal, then capture. Check that the selector is present and visible.
Lazy images are absent on a full-page capture The site loads images only as they approach the viewport. Scroll through the page before capture and wait for image loading, or use a capture service that loads lazy images as part of full-page capture.
Response is unexpectedly large or memory use spikes A tall page, large viewport, or uncompressed PNG produces a large image. Capture an element or clip region, choose an appropriate format, and cap page dimensions and concurrent jobs.
Works locally but fails on static hosting Static export has no request-time server runtime for the route. Deploy the route to a Node.js server or Docker runtime with a browser executable.
Route can reach internal services Caller-controlled URL validation is incomplete, including redirects or DNS changes. Enforce host and address restrictions at navigation time and after redirects; add authentication and rate limiting as appropriate.
Browser remains running after errors Cleanup was skipped on an exception. Close the browser in finally; if reusing a shared process, add health checks and stale-process cleanup.

8. Performance, reliability, and cost

For each capture, the main work is browser startup or acquisition, navigation, page readiness, rasterization, and returning the bytes. Reusing a browser may avoid repeated startup overhead, while increasing the need for resource limits and isolation. Full-page captures and large device scale factors increase pixel count and memory use. Choose viewport, format, and capture scope according to the consumer’s needs.

Reliability depends on the target site as well as your own route: navigation can fail, content can change, and browser processes can crash. Return useful status codes, log the failure category without logging sensitive URL query data, and set bounded timeouts. Retry only failures likely to be transient, with a limit; blindly retrying slow or blocked destinations increases load and latency.

There is no universal per-screenshot cost for this pattern. Budget for the hosting runtime, browser storage or remote browser service if used, memory and execution time, plus any storage for retained screenshots. The documented browser download size is not an estimate of total deployment cost. Provider-specific limits and prices must be checked for your selected host.

Or skip the browser setup

If you want an HTTP screenshot endpoint without packaging Puppeteer and Chrome into your Next.js runtime, ScreenshotNeo is a website screenshot API and MCP server for developers. Its API accepts one GET request with a URL and returns an image or PDF. See the API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', new Uint8Array(await res.arrayBuffer()));

In a Next.js route, call the same endpoint server-side and return its response bytes, keeping your API key in a server environment variable. ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, and failed loads are never billed; response headers report the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for AI agents. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. Create a free account and get 1,000 screenshots a month with no card.

FAQ

Can a Next.js screenshot endpoint run on the Edge Runtime?

This implementation uses Puppeteer and a browser executable in the Node.js runtime. Deploy it in a server environment that supports that browser process; do not assume Edge or static-export compatibility.

Can I return a screenshot directly from a Route Handler?

Yes. Return the screenshot bytes in a Web Response with the correct Content-Type, as in the route example.

Should I use Puppeteer or puppeteer-core?

Use puppeteer when its installation can download and package the browser. Use puppeteer-core when you supply or connect to a separately managed browser.

Does networkidle2 mean the screenshot is visually stable?

No. It is a network condition. For deterministic captures, wait for a target-specific ready signal and account for animations or lazy content.