Take Website Screenshots in Node.js: A Complete Tutorial

S
ScreenshotAPIs Team April 17, 2026 · 11 min read

Taking screenshots in Node.js usually means wrestling with Puppeteer: downloading Chromium, managing browser instances, tuning memory limits, and debugging why fonts render differently in production. This tutorial shows a simpler approach — a screenshot API you call with plain fetch() — that works from any Node.js environment, including AWS Lambda, Vercel, and minimal Docker containers.

Everything below is runnable. You'll go from a one-liner quick start to real-world recipes: full-page captures, element screenshots, dark mode, OG images from raw HTML, PDFs, batch jobs that respect rate limits, retry logic, and a production Express endpoint with caching. All you need is Node 18+ and an API key (free tier: 100 renders/month, no card).

Quick start: your first screenshot with fetch (no dependencies)

Tired of the setup? Try the API free.

100 renders / month, no credit card. Or buy credits one-time and use them whenever — credits never expire.

Start free →

Node 18 ships fetch() natively, so the minimal version needs zero packages. Create screenshot.mjs:

// screenshot.mjs — Node 18+, no dependencies
import { writeFile } from "node:fs/promises";

const res = await fetch("https://screenshotapis.org/v1/screenshot", {
  method: "POST",
  headers: {
    "X-Api-Key": process.env.SCREENSHOT_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com",
    format: "png",
  }),
});

if (!res.ok) throw new Error(`Render failed: ${res.status} ${await res.text()}`);

await writeFile("screenshot.png", Buffer.from(await res.arrayBuffer()));
console.log(`Saved. Render took ${res.headers.get("X-Render-Time-Ms")} ms`);

Run it with SCREENSHOT_API_KEY=sk_live_... node screenshot.mjs. The response body is the raw image (PNG here; jpeg and webp also work), and two useful headers come back with it: X-Render-Time-Ms (typically 900–1,600 ms for a real page) and X-Credits-Remaining. Authentication is a single X-Api-Key header — a Bearer token works too. The full parameter list lives in the API docs, and you can experiment with every option visually in the free screenshot generator before writing any code.

Since every recipe below is a POST to the same endpoint, let's factor out a tiny helper and reuse it for the rest of the tutorial:

import { writeFile } from "node:fs/promises";

const API = "https://screenshotapis.org/v1/screenshot";
const HEADERS = {
  "X-Api-Key": process.env.SCREENSHOT_API_KEY,
  "Content-Type": "application/json",
};

export async function capture(params, outfile) {
  const res = await fetch(API, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify(params),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  const buf = Buffer.from(await res.arrayBuffer());
  if (outfile) await writeFile(outfile, buf);
  return buf;
}

Or use the official SDK

If you prefer a typed client over raw HTTP, there's an npm package that wraps the same API:

npm install screenshotapis
import Client from "screenshotapis";
import { writeFileSync } from "node:fs";

const client = new Client(process.env.SCREENSHOT_API_KEY);

// Screenshot
const png = await client.screenshot({ url: "https://example.com" });
writeFileSync("screenshot.png", Buffer.from(png));

// PDF
const pdf = await client.pdf({ html: "<h1>Invoice #42</h1>" });
writeFileSync("invoice.pdf", Buffer.from(pdf));

The SDK accepts the exact same parameters as the raw API. The rest of this tutorial uses fetch() so the examples work anywhere — including edge runtimes where you may not want an extra dependency — but every recipe translates one-to-one to the SDK.

Recipe 1: full-page vs viewport captures

By default you get an above-the-fold capture at a 1280×800 viewport. Two knobs change that: viewport_width/viewport_height control the browser window, and full_page: true scrolls and stitches the entire page height.

// Above the fold at a desktop-ish viewport
await capture({
  url: "https://stripe.com",
  viewport_width: 1440,
  viewport_height: 900,
}, "hero.png");

// The whole scrollable page (great for archiving and audits)
await capture({
  url: "https://stripe.com",
  full_page: true,
  format: "jpeg",
  quality: 80,
}, "full-page.jpg");

Full-page PNGs of long pages get big fast. Switching to jpeg or webp with quality: 80 typically cuts file size by 60–80% with no visible difference in page archives.

Recipe 2: capture a single element with a CSS selector

Pass a selector and the API crops the capture to that element's bounding box — no post-processing with sharp or ImageMagick needed. This is ideal for capturing charts, pricing tables, or embeddable widgets:

await capture({
  url: "https://news.ycombinator.com",
  selector: "#hnmain",
  format: "png",
}, "front-page.png");

If the selector doesn't match anything, the render fails with a 422 (more on error semantics in Recipe 7), so you know immediately rather than getting a silently wrong crop.

Recipe 3: dark mode and retina screenshots

dark_mode: true renders the page with the prefers-color-scheme: dark media query active — the same signal a real user's dark-mode browser sends. Pair it with device_scale_factor: 2 for retina-sharp output (the API supports 0.5–3.0):

await capture({
  url: "https://tailwindcss.com",
  dark_mode: true,
  device_scale_factor: 2,   // 2x pixel density
  format: "webp",           // ~30% smaller than PNG
  block_ads: true,
  hide_cookie_banners: true,
}, "dark-retina.webp");

Two options in that snippet earn their keep in production: block_ads skips loading ad and tracking scripts (faster renders, cleaner images), and hide_cookie_banners removes the consent overlays that otherwise ruin automated captures of EU-facing sites.

Recipe 4: render raw HTML strings (invoices, OG images)

You don't need a URL at all. Pass html instead and the API renders your markup directly — perfect for dynamic social cards and receipts. Combine it with selector and retina scaling to get a pixel-exact 1200×630 OG image:

const title = "Ship screenshots without Chromium";

const html = `
  <div id="card" style="width:1200px;height:630px;background:#0f172a;
       color:#f8fafc;display:flex;align-items:center;justify-content:center;
       font-family:system-ui;font-size:56px;padding:60px;text-align:center">
    ${title}
  </div>
`;

await capture({
  html,
  selector: "#card",
  device_scale_factor: 2,
  format: "png",
}, "og-image.png");

The html and url parameters are mutually exclusive — send one or the other. If you're generating OG images in a Next.js app specifically, we wrote a dedicated guide: dynamic OG images in Next.js.

Recipe 5: generate PDFs

PDF generation is a sibling endpoint, /v1/pdf, with print-oriented options: paper format (A4, Letter, Legal, A3, Tabloid), margins, landscape, and header/footer templates. It accepts url or raw html just like screenshots:

const res = await fetch("https://screenshotapis.org/v1/pdf", {
  method: "POST",
  headers: HEADERS,
  body: JSON.stringify({
    html: "<h1>Invoice #42</h1><p>Total: $199.00</p>",
    format: "A4",
    margin_top: "2cm",
    margin_bottom: "2cm",
    print_background: true,
  }),
});
await writeFile("invoice.pdf", Buffer.from(await res.arrayBuffer()));

Want to sanity-check your HTML before wiring it into code? The free HTML-to-PDF converter runs the same rendering pipeline in your browser.

Recipe 6: batch screenshots with Promise.all (and rate limits)

Naively mapping 200 URLs through Promise.all will hit the per-minute rate limit — the Starter plan allows 30 requests/minute (limits scale with plan; see pricing). The fix is chunked concurrency:

const urls = [/* ...your URLs... */];
const CONCURRENCY = 5;

for (let i = 0; i < urls.length; i += CONCURRENCY) {
  const chunk = urls.slice(i, i + CONCURRENCY);
  await Promise.all(
    chunk.map((url, j) =>
      capture({ url, format: "webp", block_ads: true }, `shot-${i + j}.webp`)
    )
  );
}

With renders averaging around a second, 5 concurrent requests keeps you comfortably under 30/minute while still finishing 200 URLs in a few minutes. For genuinely large batches, skip polling entirely: pass a webhook_url and the API returns 202 Accepted with a render_id immediately, then POSTs the finished result to your endpoint — no connections held open.

Recipe 7: error handling and retries

Three status codes matter in day-to-day use, and they mean different things:

Here's a retry wrapper encoding those semantics:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function captureWithRetry(params, outfile, maxAttempts = 3) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const res = await fetch(API, {
      method: "POST",
      headers: HEADERS,
      body: JSON.stringify(params),
    });

    if (res.ok) {
      const buf = Buffer.from(await res.arrayBuffer());
      if (outfile) await writeFile(outfile, buf);
      return buf;
    }

    if (res.status === 429) {
      const wait = Number(res.headers.get("Retry-After") ?? "60");
      await sleep(wait * 1000);
      continue;
    }

    if (res.status === 422 && attempt < maxAttempts) {
      await sleep(2000 * attempt);  // transient render failure — backoff
      continue;
    }

    throw new Error(`${res.status}: ${await res.text()}`);  // 402, 400, etc.
  }
  throw new Error("Retries exhausted");
}

Recipe 8: an Express endpoint that proxies screenshots with caching

A common production pattern: your frontend asks your server for a link preview, and your server calls the screenshot API — keeping the key secret and letting you cache. Screenshots of the same URL rarely change minute-to-minute, so even a simple in-memory TTL cache slashes credit usage:

import express from "express";

const app = express();
const cache = new Map();            // url -> { buf, expires }
const TTL_MS = 10 * 60 * 1000;      // 10 minutes

app.get("/preview", async (req, res) => {
  const { url } = req.query;
  if (!url) return res.status(400).json({ error: "url query param required" });

  const hit = cache.get(url);
  if (hit && hit.expires > Date.now()) {
    return res.type("image/webp").send(hit.buf);
  }

  try {
    const buf = await captureWithRetry({
      url,
      format: "webp",
      block_ads: true,
      hide_cookie_banners: true,
    });
    cache.set(url, { buf, expires: Date.now() + TTL_MS });
    res.type("image/webp").send(buf);
  } catch {
    res.status(502).json({ error: "Could not render URL" });
  }
});

app.listen(3000);

One thing you don't have to build here: SSRF protection. The API rejects requests targeting localhost and internal IP ranges with a 400 before rendering, so user-supplied URLs can't be used to probe your (or our) internal network. For anything beyond a single process, swap the Map for Redis with EXPIRE — same logic, shared across instances.

Why not just use Puppeteer?

Puppeteer is the right tool when you need to interact with pages — click buttons, fill forms, walk multi-step flows. For pure capture workloads, you're signing up to operate Chromium: 200–500 MB of RAM per instance, memory-leak watchdogs, font packages for emoji and CJK, security updates every few weeks, concurrency queues, and your own SSRF filtering. On serverless it's worse — Chromium layers, cold starts, and size limits, which is why we wrote a whole guide on screenshots on AWS Lambda.

The honest tradeoff: below roughly a million renders per month, an API is cheaper than the all-in cost of self-hosting once you count engineering time, and it's hours instead of days to ship. Above that scale, or when compliance forbids data leaving your network, self-hosting wins. The full cost breakdown with real numbers is in Puppeteer vs screenshot API.

Frequently asked questions

Do I need to install Puppeteer or Chromium to take screenshots in Node.js?

No. With a screenshot API, the browser runs on the API's infrastructure. Your Node.js code makes a single HTTPS request with the built-in fetch() (Node 18+) and receives the image bytes back — no Chromium download, no system libraries, no Dockerfile changes.

How do I authenticate requests to the screenshot API?

Send your key in the X-Api-Key header on every request (a Bearer Authorization header also works). Keep the key in an environment variable and call the API from your server, never from browser-side code.

What output formats are supported?

Screenshots can be returned as PNG, JPEG, or WebP via the format parameter, with a quality setting for JPEG and WebP. PDFs are generated through the separate /v1/pdf endpoint with paper sizes A4, Letter, Legal, A3, and Tabloid.

What happens if a screenshot fails to render?

The API returns HTTP 422 with an error message — common causes are pages that time out or sites with aggressive bot protection — and the credit for the failed render is automatically refunded. Rate-limit rejections return HTTP 429 with a Retry-After header telling you how long to wait.

How fast is a typical render?

Most pages render in 0.9–1.6 seconds. You can shave that down with wait_until: "domcontentloaded" when you don't need every network request to finish, and block_ads: true to skip ad scripts entirely.

How much does it cost?

The free tier includes 100 renders per month with no credit card. Paid plans start at $19/month for 2,000 renders (Starter, 30 requests/minute) and scale up to 75,000 renders for $299/month. One-time credit packs start at $9 and never expire, which suits spiky batch workloads.